普通视图

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

React Hook 核心指南:从实战到源码,彻底掌握状态与副作用

2025年8月16日 14:07

Hook 的出现让函数组件拥有了管理状态和副作用的能力,极大地提升了代码的可读性和复用性。今天,我想和大家分享 React 中最核心、最常用的几个 Hook:useStateuseEffectuseContextuseReduceruseCallbackuseMemouseRef。我会从基本用法讲起,结合实际例子,深入探讨它们的原理和最佳实践,并手写简化版实现,最后附上高频面试题。希望这篇博客能让你对 React Hook 有更深刻的理解。

1. useState:管理组件状态

1.1 基本概念

useState 是最基础的 Hook,用于在函数组件中添加状态。它接收一个初始状态值,返回一个包含当前状态和更新函数的数组。

const [state, setState] = useState(initialState);
  • state:当前状态值。
  • setState:更新状态的函数。
  • initialState:初始状态,可以是任意值(原始值、对象、函数等)。

1.2 使用场景

计数器是最经典的例子:

import React, { useState } from 'react';

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

    return (
        <div>
            <p>当前计数: {count}</p>
            <button onClick={() => setCount(count + 1)}>
                增加
            </button>
            <button onClick={() => setCount(prevCount => prevCount - 1)}>
                减少
            </button>
        </div>
    );
}

关键点:

  • setState 是异步的,多次调用会进行批处理。
  • 更新函数可以接收一个函数,该函数的参数是前一个状态值(prevState),这在需要基于前一个状态更新时非常有用。

1.3 手写实现(简化版)

// 模拟 React 内部状态存储
let hooks = [];
let currentHookIndex = 0;

function useState(initialValue) {
    const hookIndex = currentHookIndex;

    // 如果是第一次调用,初始化状态
    if (!hooks[hookIndex]) {
        hooks[hookIndex] = initialValue;
    }

    // 返回当前状态和更新函数
    const setState = (newValue) => {
        // 如果传入的是函数,执行它
        const value = typeof newValue === 'function' 
            ? newValue(hooks[hookIndex]) 
            : newValue;
        
        hooks[hookIndex] = value;
        // 模拟触发重新渲染
        render();
    };

    return [hooks[hookIndex], setState];
}

// 模拟组件渲染
function render() {
    currentHookIndex = 0; // 重置索引
    // 重新执行组件函数
    App();
}

function App() {
    const [count, setCount] = useState(0);
    const [name, setName] = useState('张三');

    console.log('Count:', count, 'Name:', name);
}

// 初始渲染
App(); // Count: 0 Name: 张三
setCount(1); // Count: 1 Name: 张三
setName('李四'); // Count: 1 Name: 李四

注意: 这只是一个极度简化的模型,真实的 React 使用 Fiber 节点和 Hook 链表来管理状态。

1.4 注意事项

  • 不要在条件或循环中使用 Hook:React 依赖 Hook 的调用顺序来正确匹配状态。违反规则会导致 Invalid hook call 错误。
  • 状态更新是异步的setState 后立即读取状态可能得不到最新值。
  • 函数式更新:当新状态依赖于前一个状态时,使用函数形式 setState(prev => prev + 1)

2. useEffect:处理副作用

2.1 基本概念

useEffect 用于在函数组件中执行副作用操作,如数据获取、订阅、手动 DOM 操作等。它替代了类组件中的 componentDidMountcomponentDidUpdatecomponentWillUnmount

useEffect(() => {
    // 执行副作用
    return () => {
        // 清理副作用(可选)
    };
}, [dependencies]); // 依赖数组

2.2 使用场景

场景一:数据获取

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        // 模拟 API 请求
        const fetchUser = async () => {
            setLoading(true);
            try {
                // 假设这里调用 API
                const response = await fetch(`/api/users/${userId}`);
                const userData = await response.json();
                setUser(userData);
            } catch (error) {
                console.error('获取用户信息失败:', error);
            } finally {
                setLoading(false);
            }
        };

        fetchUser();
    }, [userId]); // 仅当 userId 变化时重新执行

    if (loading) return <div>加载中...</div>;
    if (!user) return <div>用户不存在</div>;

    return (
        <div>
            <h1>{user.name}</h1>
            <p>{user.email}</p>
        </div>
    );
}

场景二:订阅和清理

import React, { useState, useEffect } from 'react';

function MouseTracker() {
    const [position, setPosition] = useState({ x: 0, y: 0 });

    useEffect(() => {
        const handleMouseMove = (event) => {
            setPosition({ x: event.clientX, y: event.clientY });
        };

        // 添加事件监听器
        window.addEventListener('mousemove', handleMouseMove);

        // 返回清理函数
        return () => {
            window.removeEventListener('mousemove', handleMouseMove);
        };
    }, []); // 空依赖数组,只在挂载时执行一次

    return (
        <div>
            鼠标位置: {position.x}, {position.y}
        </div>
    );
}

2.3 依赖数组详解

  • []:只在组件挂载时执行一次(类似 componentDidMount)。
  • [dep1, dep2]:当 dep1dep2 变化时执行。
  • 不传:每次组件重新渲染时都执行(不推荐,容易造成性能问题或无限循环)。

2.4 手写实现(简化版)

let dependencies = [];
let cleanup = null;

function useEffect(callback, deps) {
    const hasChanged = !deps || 
        deps.some((dep, index) => !dependencies[index] || dep !== dependencies[index]);

    if (hasChanged) {
        // 执行清理函数(如果存在)
        if (cleanup) {
            cleanup();
        }
        // 执行副作用
        cleanup = callback();
        // 更新依赖
        dependencies = deps || [];
    }
}

// 模拟组件
function Component({ count }) {
    useEffect(() => {
        console.log('Effect 执行,count:', count);
        return () => {
            console.log('清理 Effect,count:', count);
        };
    }, [count]);

    return <div>Count: {count}</div>;
}

// 初始渲染
Component({ count: 0 }); // Effect 执行,count: 0

// 更新
Component({ count: 1 }); // 清理 Effect,count: 0 -> Effect 执行,count: 1

2.5 注意事项

  • 必须返回函数或 undefined:清理函数必须是同步的。
  • 避免无限循环:确保依赖数组正确,避免在 useEffect 内部更新依赖项。
  • 异步操作:不能直接在 useEffect 回调中使用 async,需要在内部创建异步函数。
// ❌ 错误
useEffect(async () => {
    const data = await fetchData();
}, []);

// ✅ 正确
useEffect(() => {
    const fetchData = async () => {
        const data = await fetchData();
    };
    fetchData();
}, []);

3. useContext:跨组件传递数据

3.1 基本概念

useContext 用于订阅 React 的 Context。它接收一个 context 对象(React.createContext 的返回值),并返回当前 context 的值。

const value = useContext(MyContext);

3.2 使用场景

主题切换是经典例子:

import React, { createContext, useContext, useState } from 'react';

// 创建 Context
const ThemeContext = createContext();

// 主题提供者组件
function ThemeProvider({ children }) {
    const [theme, setTheme] = useState('light');

    const toggleTheme = () => {
        setTheme(prev => prev === 'light' ? 'dark' : 'light');
    };

    return (
        <ThemeContext.Provider value={{ theme, toggleTheme }}>
            {children}
        </ThemeContext.Provider>
    );
}

// 使用 Context 的组件
function Header() {
    const { theme, toggleTheme } = useContext(ThemeContext);

    return (
        <header style={{ background: theme === 'light' ? '#fff' : '#333', color: theme === 'light' ? '#000' : '#fff' }}>
            <h1>我的网站</h1>
            <button onClick={toggleTheme}>
                切换到 {theme === 'light' ? '暗色' : '亮色'} 主题
            </button>
        </header>
    );
}

// 根组件
function App() {
    return (
        <ThemeProvider>
            <Header />
            <MainContent />
        </ThemeProvider>
    );
}

3.3 手写实现(简化版)

// 模拟 Context
const contextStack = [];

function createContext(defaultValue) {
    return { defaultValue };
}

function Provider({ context, value, children }) {
    contextStack.push(value);
    const result = children;
    contextStack.pop();
    return result;
}

function useContext(context) {
    return contextStack[contextStack.length - 1] || context.defaultValue;
}

// 使用
const ThemeContext = createContext('light');

function ThemeProvider({ children }) {
    const [theme] = useState('dark');
    return Provider({ context: ThemeContext, value: theme, children });
}

function ThemeDisplay() {
    const theme = useContext(ThemeContext);
    return <div>当前主题: {theme}</div>;
}

3.4 注意事项

  • useContext 会订阅 Context 的变化,当 Providervalue 变化时,所有使用该 Context 的组件都会重新渲染。
  • 避免将 Context 用于频繁变化的状态,可能导致性能问题。

4. useReducer:管理复杂状态逻辑

4.1 基本概念

useReduceruseState 的替代方案,适用于状态逻辑较复杂的情况(如包含多个子值或下一个状态依赖于前一个状态)。它接收一个 reducer 函数和初始状态,返回当前状态和 dispatch 函数。

const [state, dispatch] = useReducer(reducer, initialState);
  • reducer(state, action) => newState,纯函数。
  • initialState:初始状态。

4.2 使用场景

管理表单或购物车状态:

import React, { useReducer } from 'react';

// 定义 action 类型
const ADD_ITEM = 'ADD_ITEM';
const REMOVE_ITEM = 'REMOVE_ITEM';
const UPDATE_QUANTITY = 'UPDATE_QUANTITY';

// Reducer 函数
function cartReducer(state, action) {
    switch (action.type) {
        case ADD_ITEM:
            return {
                ...state,
                items: [...state.items, action.payload]
            };
        case REMOVE_ITEM:
            return {
                ...state,
                items: state.items.filter(item => item.id !== action.payload.id)
            };
        case UPDATE_QUANTITY:
            return {
                ...state,
                items: state.items.map(item =>
                    item.id === action.payload.id
                        ? { ...item, quantity: action.payload.quantity }
                        : item
                )
            };
        default:
            return state;
    }
}

function ShoppingCart() {
    const [state, dispatch] = useReducer(cartReducer, { items: [] });

    const addItem = (item) => {
        dispatch({ type: ADD_ITEM, payload: item });
    };

    const removeItem = (id) => {
        dispatch({ type: REMOVE_ITEM, payload: { id } });
    };

    return (
        <div>
            <h2>购物车</h2>
            <ul>
                {state.items.map(item => (
                    <li key={item.id}>
                        {item.name} - 数量: {item.quantity}
                        <button onClick={() => removeItem(item.id)}>删除</button>
                    </li>
                ))}
            </ul>
            <button onClick={() => addItem({ id: Date.now(), name: '商品', quantity: 1 })}>
                添加商品
            </button>
        </div>
    );
}

4.3 手写实现(简化版)

function useReducer(reducer, initialState) {
    const [state, setState] = useState(initialState);

    const dispatch = (action) => {
        const newState = reducer(state, action);
        setState(newState);
    };

    return [state, dispatch];
}

// 使用
const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
        case 'INCREMENT':
            return { count: state.count + 1 };
        default:
            return state;
    }
}, { count: 0 });

4.4 注意事项

  • useReducer 适合状态更新逻辑复杂或需要处理多个 action 的场景。
  • reducer 必须是纯函数。

5. useCallback:缓存函数

5.1 基本概念

useCallback 返回一个记忆化的回调函数。它接收一个内联回调函数和依赖数组,只有当依赖项变化时,才会返回新的函数。

const memoizedCallback = useCallback(
    () => {
        doSomething(a, b);
    },
    [a, b],
);

5.2 使用场景

避免子组件不必要的重新渲染:

import React, { useState, useCallback } from 'react';

// 子组件(使用 React.memo 优化)
const ExpensiveComponent = React.memo(({ onClick, value }) => {
    console.log('ExpensiveComponent 渲染');
    return (
        <button onClick={onClick}>
            点击我 ({value})
        </button>
    );
});

function Parent() {
    const [count, setCount] = useState(0);
    const [text, setText] = useState('');

    // 使用 useCallback 缓存函数
    const handleClick = useCallback(() => {
        setCount(prev => prev + 1);
    }, []); // 依赖数组为空,函数不会变化

    return (
        <div>
            <p>计数: {count}</p>
            <input value={text} onChange={e => setText(e.target.value)} />
            {/* 传递缓存的函数 */}
            <ExpensiveComponent onClick={handleClick} value={count} />
        </div>
    );
}

如果没有 useCallback,每次 Parent 重新渲染时,handleClick 都会是一个新的函数,导致 ExpensiveComponent 重新渲染。

5.3 手写实现(简化版)

let memoizedCallback = null;
let deps = null;

function useCallback(callback, dependencies) {
    const hasChanged = !deps || 
        dependencies.some((dep, index) => !deps[index] || dep !== deps[index]);

    if (hasChanged) {
        memoizedCallback = callback;
        deps = dependencies;
    }

    return memoizedCallback;
}

5.4 注意事项

  • 只在需要传递给子组件且子组件使用 React.memo 时才使用 useCallback
  • 过度使用 useCallback 可能导致内存占用增加。

6. useMemo:缓存计算结果

6.1 基本概念

useMemo 返回一个记忆化的值。它接收一个计算函数和依赖数组,只有当依赖项变化时,才会重新计算。

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

6.2 使用场景

优化昂贵的计算:

import React, { useState, useMemo } from 'react';

function Fibonacci({ n }) {
    // 模拟昂贵的计算
    const fibonacci = (num) => {
        if (num <= 1) return num;
        return fibonacci(num - 1) + fibonacci(num - 2);
    };

    // 使用 useMemo 缓存计算结果
    const result = useMemo(() => fibonacci(n), [n]);

    return <div>斐波那契数列第 {n} 项: {result}</div>;
}

function App() {
    const [n, setN] = useState(10);
    const [input, setInput] = useState('');

    return (
        <div>
            <input value={input} onChange={e => setInput(e.target.value)} placeholder="输入文本" />
            <Fibonacci n={n} />
            <button onClick={() => setN(prev => prev + 1)}>增加 n</button>
        </div>
    );
}

如果没有 useMemo,每次 input 变化导致 App 重新渲染时,都会重新计算 fibonacci(n)

6.3 手写实现(简化版)

let memoizedValue = null;
let deps = null;

function useMemo(factory, dependencies) {
    const hasChanged = !deps || 
        dependencies.some((dep, index) => !deps[index] || dep !== deps[index]);

    if (hasChanged) {
        memoizedValue = factory();
        deps = dependencies;
    }

    return memoizedValue;
}

6.4 注意事项

  • 只在计算确实昂贵时使用 useMemo
  • 不要为了“优化”而使用,React 的重新渲染本身并不慢。

7. useRef:获取 DOM 节点或保存可变值

7.1 基本概念

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数。ref 对象在组件的整个生命周期内保持不变。

const refContainer = useRef(initialValue);

7.2 使用场景

场景一:访问 DOM 元素

import React, { useRef, useEffect } from 'react';

function TextInputWithFocusButton() {
    const inputEl = useRef(null);

    const onButtonClick = () => {
        // 直接操作 DOM
        inputEl.current.focus();
    };

    return (
        <>
            <input ref={inputEl} type="text" />
            <button onClick={onButtonClick}>
                聚焦输入框
            </button>
        </>
    );
}

场景二:保存可变值(不触发重新渲染)

import React, { useState, useEffect, useRef } from 'react';

function Timer() {
    const [count, setCount] = useState(0);
    // 使用 ref 保存计时器 ID
    const intervalRef = useRef();

    useEffect(() => {
        intervalRef.current = setInterval(() => {
            setCount(prev => prev + 1);
        }, 1000);

        return () => {
            // 清理时使用 ref
            clearInterval(intervalRef.current);
        };
    }, []);

    return <div>计数: {count}</div>;
}

7.3 手写实现(简化版)

function useRef(initialValue) {
    const refObject = { current: initialValue };
    return refObject;
}

7.4 注意事项

  • useRef 返回的对象在组件生命周期内是同一个。
  • 修改 .current 不会触发组件重新渲染。
  • 可以用于保存任何可变值,如定时器 ID、上一次的 props 等。

8. 常见面试题

  1. useStatesetState 是同步还是异步?

    • 在 React 事件处理中是异步批处理的;在 setTimeout 或原生事件中是同步的。
  2. useEffect 的清理函数在什么时候执行?

    • 在组件卸载时,或在下一次 useEffect 执行前(如果依赖变化)。
  3. useCallbackuseMemo 的区别是什么?

    • useCallback 缓存函数,useMemo 缓存值。useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
  4. 如何在 useEffect 中使用 async 函数?

    • useEffect 回调内部定义并立即调用一个 async 函数。
  5. useRefcreateRef 的区别?

    • useRef 在函数组件中使用,每次渲染返回同一个对象;createRef 每次调用都返回新对象,通常在类组件中使用。
  6. 为什么不能在条件中使用 Hook?

    • React 依赖 Hook 的调用顺序来正确匹配状态,条件使用会破坏这个顺序。
  7. useReducer 适合什么场景?

    • 状态逻辑复杂、包含多个子值、或下一个状态依赖于前一个状态时。
  8. useContext 会导致性能问题吗?

    • 如果 Providervalue 频繁变化,可能会导致所有订阅的组件重新渲染。可以使用 useMemo 缓存 value 或拆分 Context

结语

React Hook 让函数组件变得强大而灵活。掌握这些核心 Hook 的用法、原理和最佳实践,是成为一名优秀 React 开发者的关键。记住:

  • useState:管理简单状态。
  • useEffect:处理副作用,注意依赖和清理。
  • useContext:跨层级传递数据。
  • useReducer:管理复杂状态逻辑。
  • useCallback / useMemo:性能优化,按需使用。
  • useRef:访问 DOM 或保存可变值。

在实际项目中,多思考、多实践,你会发现 Hook 的组合能解决各种复杂的 UI 问题。希望这篇博客能成为你深入 React 的坚实阶梯。

前端跨域全解析:从 CORS 到 postMessage,再到 WebSocket

作者 yvvvy
2025年8月16日 14:02

小白友好版,跨域不再迷路


1️⃣ 什么是跨域?

跨域,简单理解就是:

“浏览器:哎呀,这请求要跑到别人家去拿数据,我敢不敢让它去呢?”

严格说法:当浏览器从一个源(Origin)请求另一个源(Origin)的资源时,如果两者不一样,就触发同源策略(Same-Origin Policy),这就是跨域。

源(Origin)组成:

源 = 协议(Protocol) + 域名(Host) + 端口(Port)

三者任意一项不同,就算跨域。

为什么有同源策略?

  • 防止 CSRF(跨站请求伪造)
  • 防止 XSS(跨站脚本攻击)
  • 防止隐私泄露(Cookie、账户信息)

举个例子:

http://example.com:80/page1.html  →  http://api.example.com:80/data

域名不同 → 跨域


2️⃣ CORS(跨域资源共享)

CORS 就像浏览器和服务器的“通行证”,谁允许你进,谁说了算。

浏览器:我想拿数据!
服务器:你是安全的,我允许你。
浏览器:好,我拿走数据!

2.1 两种 CORS 请求

🔹 简单请求(Simple Request)

条件(全部满足):

  1. 方法:GET / POST / HEAD
  2. 请求头:只能是浏览器安全集合
  3. 请求体:安全格式(纯文本/表单)
// 简单请求示例
fetch('https://api.example.com/data', {
  method: 'GET',
})
  .then(res => res.json())
  .then(data => console.log(data));

服务器只需允许跨域即可:

Access-Control-Allow-Origin: *

🔹 预检请求(Preflight Request)

触发条件:

  • 方法不是 GET/POST/HEAD
  • 自定义请求头(如 X-Token
  • Content-Type 非简单类型

流程示意:

浏览器:我想PUT数据,可以吗?(OPTIONS预检)
服务器:可以,你的来源、方法、头都允许
浏览器:好,发真正请求

代码示例:

fetch('https://api.example.com/data', {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'X-Token': '123456'
  },
  body: JSON.stringify({ name: '跨域小白' })
})
.then(res => res.json())
.then(data => console.log(data));

面试小技巧:能区分“简单请求 vs 预检请求”,CORS 面试题轻松过。


3️⃣ JSONP

JSONP 是老派跨域方式,利用 <script> 标签天生跨域的特性。

<script>
  function handleData(data) {
    console.log('拿到跨域数据啦!', data);
  }
</script>
<script src="https://api.example.com/data?callback=handleData"></script>

优点:兼容老浏览器、实现简单
缺点:只能 GET、XSS 风险、错误处理麻烦

现代项目基本不用 JSONP,直接 CORS + Fetch 就好。


4️⃣ postMessage

当你和不同源的 iframe/窗口要聊聊时,postMessage 就像安全的对讲机。

<!-- 父页面 -->
<iframe id="child" src="https://other.com"></iframe>
<script>
  const iframe = document.getElementById('child');
  iframe.contentWindow.postMessage({ msg: 'Hi小伙伴' }, 'https://other.com');

  window.addEventListener('message', (event) => {
    if(event.origin !== 'https://other.com') return; // 安全检查
    console.log('收到子页面消息:', event.data);
  });
</script>

记住:安全第一,一定要检查 event.origin


5️⃣ WebSocket

WebSocket 就是“前端的即时聊天神器”,浏览器和服务器可以随时互发消息。

const ws = new WebSocket('wss://example.com/socket');

ws.onopen = () => ws.send('hello server!');

ws.onmessage = (msg) => console.log('收到服务器消息:', msg.data);

ws.onclose = () => console.log('连接关闭');

特点:

  • 全双工
  • 单连接
  • 跨域天然支持

6️⃣ 跨域常见应用场景

场景 示例 解决方案 代码示例
前端调用后端 API 开发 localhost → 远端 API CORS / 反向代理 fetch('https://api.example.com')
第三方接口 高德地图、支付 CORS / JSONP fetch('https://maps.com/api')
跨域 iframe 通信 支付 iframe postMessage iframe.contentWindow.postMessage(...)
多窗口/标签页 登录状态同步 postMessage + window.open window.opener.postMessage(...)
Web Worker 跨域 Worker 加载脚本 postMessage + CORS worker.postMessage(...)
静态资源跨域 CDN JS/CSS/图片 允许跨域 <script src="https://cdn.com/lib.js"></script>

7️⃣ 面试问答专栏:跨域篇

1️⃣ 面试官问:什么是跨域?

回答示例

“跨域就是浏览器发现你要去访问别人家的资源,它会先问一句:我敢不敢让它去?
严格来说,就是源(协议 + 域名 + 端口)不同,就触发同源策略。”


2️⃣ 面试官问:什么是 CORS?

回答示例

“CORS 就是浏览器和服务器的通行证,服务器在响应头声明允许的源、方法、头,浏览器通过才交数据给前端。
简单请求直接发,复杂请求会先发 OPTIONS 预检。”

代码示例:

fetch('https://api.example.com/data')
  .then(res => res.json())
  .then(data => console.log(data));

3️⃣ 面试官问:JSONP 和 CORS 有什么区别?

回答示例

“JSONP 是老派方案,靠 <script> 标签跨域,只能 GET,有 XSS 风险。
CORS 是现代方案,更安全灵活,支持 POST/PUT/DELETE。”


4️⃣ 面试官问:postMessage 是什么?

回答示例

“父页面和 iframe 或者窗口与 Worker 需要互相通信时,用 postMessage 传消息。安全重点是检查 event.origin。”

代码示例:

iframe.contentWindow.postMessage({ msg: 'hello' }, 'https://other.com');

5️⃣ 面试官问:WebSocket 跨域吗?

回答示例

“WebSocket 建立的是 TCP 长连接,一旦连接建立就天然跨域,可以双向通信。”


8️⃣ 总结

跨域知识点其实就像“安检关卡”,理解它,你就能安全访问资源,而且面试题轻松拿分。

技术 适用场景 优点 注意点
CORS 前后端接口跨域 简单灵活 后端需支持
JSONP 老 GET 请求 兼容老浏览器 只能 GET、有 XSS 风险
postMessage iframe/窗口/Worker 通信 安全灵活 检查 origin
WebSocket 实时通信 高效全双工 服务端支持

小结:

  • CORS → 现代前端首选
  • JSONP → 老项目遗留
  • postMessage → 窗口/iframe/Worker 通信
  • WebSocket → 实时双向通信

跨域学会了,你就是前端安全小能手!


深入理解 Promise 的高阶用法:从入门到手写实现

2025年8月16日 14:00

今天,我想和大家深入聊聊 Promise 的几个高阶方法:Promise.allPromise.racePromise.allSettledPromise.any。这些方法在处理多个异步任务时非常有用,也是面试中的常客。我会从基本用法讲起,结合实际例子,最后手写实现这些方法,并附上常见的面试题。

1. Promise.all:等待所有任务完成

1.1 基本概念

Promise.all 是一个静态方法,它接收一个可迭代对象(通常是数组),其中包含多个 Promise 实例。它的作用是等待所有 Promise 都成功完成,然后返回一个新 Promise,该 Promise 的结果是一个包含所有 Promise 结果的数组。

关键点:

  • 所有 Promise 都必须成功,Promise.all 返回的 Promise 才会成功。
  • 如果其中任何一个 Promise 失败(rejected),Promise.all 会立即失败,并返回第一个失败的 Promise 的错误。

1.2 使用场景

想象一下,你正在开发一个电商网站,需要同时获取用户信息、购物车数据和推荐商品列表。这三个请求是独立的,但你希望在所有数据都准备好后,再渲染页面。

// 模拟三个异步请求
function fetchUserInfo() {
    return new Promise((resolve) => {
        setTimeout(() => resolve({ name: '张三', age: 25 }), 1000);
    });
}

function fetchCartData() {
    return new Promise((resolve) => {
        setTimeout(() => resolve(['商品A', '商品B']), 800);
    });
}

function fetchRecommendations() {
    return new Promise((resolve) => {
        setTimeout(() => resolve(['商品C', '商品D']), 1200);
    });
}

// 使用 Promise.all
Promise.all([fetchUserInfo(), fetchCartData(), fetchRecommendations()])
    .then(([userInfo, cartData, recommendations]) => {
        console.log('用户信息:', userInfo);
        console.log('购物车:', cartData);
        console.log('推荐商品:', recommendations);
        // 渲染页面
    })
    .catch(error => {
        console.error('获取数据失败:', error);
        // 处理错误,比如显示错误提示
    });

在这个例子中,Promise.all 等待所有三个请求都成功后,才执行 .then 回调。如果任何一个请求失败(比如网络错误),Promise.all 会立即进入 .catch 分支。

1.3 手写实现

现在我们来手写一个 Promise.all 的简化版本:

function myPromiseAll(promises) {
    // 返回一个新的 Promise
    return new Promise((resolve, reject) => {
        // 如果传入的不是可迭代对象,直接 resolve 空数组
        if (!Array.isArray(promises)) {
            return resolve([]);
        }

        const results = [];
        let completedCount = 0;

        // 如果传入空数组,直接 resolve
        if (promises.length === 0) {
            return resolve(results);
        }

        promises.forEach((promise, index) => {
            // 使用 Promise.resolve 确保每个元素都是 Promise
            Promise.resolve(promise)
                .then(value => {
                    results[index] = value;
                    completedCount++;

                    // 当所有 Promise 都完成时,resolve 结果数组
                    if (completedCount === promises.length) {
                        resolve(results);
                    }
                })
                .catch(error => {
                    // 任何一个 Promise 失败,立即 reject
                    reject(error);
                });
        });
    });
}

// 测试
myPromiseAll([
    Promise.resolve('第一个'),
    new Promise(resolve => setTimeout(() => resolve('第二个'), 500)),
    '第三个' // 普通值也会被包装成 Promise
]).then(results => {
    console.log(results); // ['第一个', '第二个', '第三个']
});

1.4 注意事项

  • Promise.all 的结果数组顺序与传入的 Promise 数组顺序一致,不保证执行顺序
  • 如果传入的 Promise 中有 rejectPromise.all 会立即失败,不会等待其他 Promise 完成。

2. Promise.race:谁先完成就用谁

2.1 基本概念

Promise.race 也是接收一个 Promise 数组,但它只关心第一个完成的 Promise,无论是成功还是失败。一旦有 Promise 完成,Promise.race 就会立即返回那个结果。

关键点:

  • 返回第一个完成的 Promise 的结果或错误。
  • 适用于超时控制或竞态场景。

2.2 使用场景

最常见的用途是实现请求超时。比如,我们希望一个请求在 3 秒内完成,否则就认为它失败了。

// 模拟一个可能很慢的请求
function slowRequest() {
    return new Promise(resolve => {
        setTimeout(() => resolve('请求成功'), 5000); // 5秒
    });
}

// 创建一个超时的 Promise
function timeout(ms) {
    return new Promise((_, reject) => {
        setTimeout(() => reject(new Error(`请求超时: ${ms}ms`)), ms);
    });
}

// 使用 Promise.race 实现超时
Promise.race([slowRequest(), timeout(3000)])
    .then(result => {
        console.log('成功:', result);
    })
    .catch(error => {
        console.error('失败:', error.message); // 输出: 失败: 请求超时: 3000ms
    });

在这个例子中,timeout(3000) 会在 3 秒后 reject,而 slowRequest() 需要 5 秒。由于 timeout 先完成,Promise.race 会立即进入 .catch 分支。

2.3 手写实现

function myPromiseRace(promises) {
    return new Promise((resolve, reject) => {
        if (!Array.isArray(promises) || promises.length === 0) {
            return reject(new Error('Promise.race requires a non-empty iterable'));
        }

        promises.forEach(promise => {
            Promise.resolve(promise)
                .then(resolve)  // 第一个 resolve 或 reject 会决定最终结果
                .catch(reject);
        });
    });
}

// 测试
myPromiseRace([
    new Promise(resolve => setTimeout(() => resolve('A'), 100)),
    new Promise(resolve => setTimeout(() => resolve('B'), 50)),
    new Promise((_, reject) => setTimeout(() => reject(new Error('C')), 30))
])
.then(result => {
    console.log(result); // 不会执行,因为 C 先 reject
})
.catch(error => {
    console.error(error.message); // 输出: C
});

2.4 注意事项

  • Promise.race 的“完成”包括 resolvereject。只要有一个 Promise 结束,它就结束。
  • 适合用于竞态场景,比如多个数据源中取最快的一个。

3. Promise.allSettled:等待所有任务尘埃落定

3.1 基本概念

Promise.allSettled 是 ES2020 引入的新方法。它和 Promise.all 类似,都会等待所有 Promise 完成,但关键区别在于:它不会因为某个 Promise 失败而中断。无论成功还是失败,它都会等待所有 Promise 结束,并返回一个描述每个 Promise 结果的对象数组。

返回值结构:

  • 成功的 Promise{ status: 'fulfilled', value: result }
  • 失败的 Promise{ status: 'rejected', reason: error }

3.2 使用场景

当你需要知道所有异步任务的结果,而不关心它们是否成功时,Promise.allSettled 就派上用场了。比如批量上传文件,你希望知道每个文件的上传结果,而不是因为一个失败就放弃。

function uploadFile(filename) {
    return new Promise((resolve, reject) => {
        const success = Math.random() > 0.3; // 模拟 70% 成功率
        setTimeout(() => {
            success ? resolve(`${filename} 上传成功`) : reject(new Error(`${filename} 上传失败`));
        }, 1000);
    });
}

const files = ['file1.txt', 'file2.txt', 'file3.txt', 'file4.txt'];

Promise.allSettled(files.map(uploadFile))
    .then(results => {
        results.forEach((result, index) => {
            if (result.status === 'fulfilled') {
                console.log(`${files[index]}: ✅ ${result.value}`);
            } else {
                console.log(`${files[index]}: ❌ ${result.reason.message}`);
            }
        });
    });

输出可能类似于:

file1.txt: ✅ file1.txt 上传成功
file2.txt: ❌ file2.txt 上传失败
file3.txt: ✅ file3.txt 上传成功
file4.txt: ✅ file4.txt 上传成功

3.3 手写实现

function myPromiseAllSettled(promises) {
    return new Promise((resolve) => {
        if (!Array.isArray(promises)) {
            return resolve([]);
        }

        const results = [];
        let settledCount = 0;

        if (promises.length === 0) {
            return resolve(results);
        }

        promises.forEach((promise, index) => {
            Promise.resolve(promise)
                .then(value => {
                    results[index] = { status: 'fulfilled', value };
                    settledCount++;
                    if (settledCount === promises.length) {
                        resolve(results);
                    }
                })
                .catch(reason => {
                    results[index] = { status: 'rejected', reason };
                    settledCount++;
                    if (settledCount === promises.length) {
                        resolve(results);
                    }
                });
        });
    });
}

// 测试
myPromiseAllSettled([
    Promise.resolve('成功1'),
    Promise.reject(new Error('失败1')),
    '普通值'
]).then(results => {
    console.log(results);
    // [
    //   { status: 'fulfilled', value: '成功1' },
    //   { status: 'rejected', reason: Error: 失败1 },
    //   { status: 'fulfilled', value: '普通值' }
    // ]
});

3.4 注意事项

  • Promise.allSettled 永远不会 reject,它总是 resolve 一个结果数组。
  • 适合用于批量操作,需要收集所有结果的场景。

4. Promise.any:至少有一个成功

4.1 基本概念

Promise.any 是 ES2021 引入的方法。它会等待第一个成功(fulfilled)的 Promise,并返回其结果。只有当所有 Promise 都失败时,它才会 reject,并返回一个 AggregateError,其中包含所有失败的原因。

关键点:

  • 返回第一个成功的 Promise 的结果。
  • 只有当所有 Promise 都失败时,才会 reject。

4.2 使用场景

想象你需要从多个镜像源下载一个文件,只要有一个源成功,就可以下载。这比 Promise.race 更合适,因为 race 会返回第一个结果(可能是失败),而 any 只关心成功。

function downloadFromSource(source) {
    return new Promise((resolve, reject) => {
        const success = Math.random() > 0.6; // 模拟 40% 成功率
        setTimeout(() => {
            success ? resolve(`从 ${source} 下载成功`) : reject(new Error(`从 ${source} 下载失败`));
        }, 1000 + Math.random() * 1000);
    });
}

const sources = ['源A', '源B', '源C', '源D'];

Promise.any(sources.map(downloadFromSource))
    .then(result => {
        console.log('下载成功:', result);
    })
    .catch(error => {
        console.error('所有源都失败:', error.errors); // AggregateError
    });

如果至少有一个源成功,就会进入 .then;如果全部失败,才会进入 .catch

4.3 手写实现

function myPromiseAny(promises) {
    return new Promise((resolve, reject) => {
        if (!Array.isArray(promises) || promises.length === 0) {
            return reject(new AggregateError([], 'All promises were rejected'));
        }

        const errors = [];
        let rejectedCount = 0;

        promises.forEach(promise => {
            Promise.resolve(promise)
                .then(resolve) // 第一个成功就 resolve
                .catch(error => {
                    errors.push(error);
                    rejectedCount++;
                    if (rejectedCount === promises.length) {
                        reject(new AggregateError(errors, 'All promises were rejected'));
                    }
                });
        });
    });
}

// 测试
myPromiseAny([
    Promise.reject(new Error('失败1')),
    new Promise(resolve => setTimeout(() => resolve('成功2'), 500)),
    Promise.reject(new Error('失败3'))
])
.then(result => {
    console.log(result); // 成功2
})
.catch(error => {
    console.error('全部失败:', error.errors);
});

4.4 注意事项

  • Promise.any 会忽略失败,直到找到第一个成功。
  • 如果所有 Promise 都失败,它会 reject 一个 AggregateError

5. 四种方法对比总结

方法 成功条件 失败条件 返回值 适用场景
Promise.all 所有成功 任一失败 成功值数组 所有任务必须成功
Promise.race 第一个完成(无论成功/失败) 第一个完成(无论成功/失败) 第一个结果 竞态、超时
Promise.allSettled 所有完成(无论成功/失败) 永不失败 结果对象数组 收集所有结果
Promise.any 第一个成功 所有失败 第一个成功值 至少一个成功

6. 常见面试题

  1. Promise.allPromise.race 的区别是什么?

    • all 等待所有成功,race 等待第一个完成。
  2. 如果 Promise.all 中有一个 Promise 失败,会发生什么?

    • Promise.all 会立即 reject,并返回第一个失败的 Promise 的错误。
  3. Promise.allSettledPromise.all 有什么不同?

    • allSettled 不会因为某个 Promise 失败而中断,它会等待所有 Promise 完成并返回结果。
  4. Promise.any 在什么情况下会 reject?

    • 只有当所有 Promise 都 reject 时,Promise.any 才会 reject。
  5. 手写 Promise.all 的实现。

    • 如上文所示,注意处理空数组、非数组输入和 Promise.resolve 包装。
  6. 如何实现一个带超时功能的 Promise

    • 使用 Promise.race,结合一个定时 reject 的 Promise
  7. Promise.all 的结果数组顺序是否与传入数组一致?

    • 是的,顺序与传入的 Promise 数组顺序一致。
  8. 为什么 Promise.all 要用 Promise.resolve 包装每个元素?

    • 为了确保每个元素都是 Promise,兼容普通值或 thenable 对象。

结语

Promise 的这些高阶方法极大地丰富了我们处理异步操作的能力。理解它们的差异和适用场景,不仅能写出更健壮的代码,也能在面试中游刃有余。记住,选择哪个方法取决于你的业务需求:

  • 全部成功?用 all
  • 第一个结果?用 race
  • 所有结果?用 allSettled
  • 至少一个成功?用 any

希望这篇博客能帮你彻底掌握这些 Promise 的高阶用法。在实际开发中多尝试使用它们,你会发现异步编程可以如此优雅。

手把手教你用VS Code玩转Gitee协作:从环境配置到高效提交

2025年8月16日 12:46

✨点击上方关注☝️,追踪不迷路!

一、前言

在现代软件开发中,版本控制和团队协作是不可或缺的环节。作为国内流行的代码托管平台,Gitee(码云)提供了稳定、高效的代码托管服务;而VS Code作为一款轻量级但功能强大的代码编辑器,深受开发者喜爱。本文将详细介绍如何在VS Code中配置Gitee环境,实现从代码编写到提交的全流程高效协作。

二、环境准备

2.1 安装必要软件

首先,我们需要安装以下软件:

  1. VS Code:前往VS Code官网下载并安装最新版本
  2. Git:前往Git官网下载并安装
  3. Gitee账号:前往Gitee官网注册账号

2.2 配置Git环境

安装完成后,我们需要配置Git的用户信息:

# 配置用户名
git config --global user.name "你的Gitee用户名"

# 配置邮箱
git config --global user.email "你的Gitee注册邮箱"

# 查看配置是否成功
git config --list

三、VS Code配置Gitee

3.1 安装Gitee相关插件

在VS Code中,我们可以通过安装插件来增强与Gitee的集成:

  1. 打开VS Code,点击左侧的扩展图标(或使用快捷键Ctrl+Shift+X
  2. 搜索并安装以下插件:
    • Gitee:官方提供的Gitee集成插件
    • GitLens:增强Git功能的强大插件
    • Chinese (Simplified) Language Pack:中文语言包(可选)

3.2 配置Gitee插件

安装完成后,我们需要配置Gitee插件:

  1. 点击VS Code左下角的设置图标,选择"设置"
  2. 在搜索框中输入"gitee",找到Gitee相关设置
  3. 点击"编辑 in settings.json",添加以下配置:
{
  "gitee.username": "你的Gitee用户名",
  "gitee.password": "你的Gitee密码或私人令牌"
}

提示:为了安全起见,建议使用私人令牌而非密码。可以在Gitee的"设置-安全设置-私人令牌"中生成。

四、从Gitee克隆仓库

4.1 获取仓库地址

  1. 登录Gitee,进入你想要克隆的仓库页面
  2. 点击右侧的"克隆/下载"按钮,复制HTTPS或SSH地址

4.2 在VS Code中克隆

  1. 在VS Code中按下Ctrl+Shift+P,输入"Git: Clone"
  2. 粘贴之前复制的仓库地址,选择本地保存路径
  3. 等待克隆完成后,点击"打开仓库"

五、本地开发与提交

5.1 创建和切换分支

在实际开发中,我们通常会为不同的功能或修复创建单独的分支:

# 创建新分支
git checkout -b feature-new-function

# 查看当前分支
git branch

# 切换回主分支
git checkout main

VS Code也提供了图形化界面来管理分支:点击左下角的分支名称,即可查看和切换分支。

5.2 编辑和保存代码

在VS Code中编辑代码时,Git会自动跟踪文件的变化。你可以通过以下方式查看文件状态:

  1. 点击左侧的源代码管理图标(或使用快捷键Ctrl+Shift+G
  2. 在这里你可以看到已修改、已暂存和未跟踪的文件

5.3 暂存和提交

当你完成一段代码的编写后,可以进行暂存和提交:

  1. 在源代码管理面板中,选中要暂存的文件,点击旁边的"+"号
  2. 在消息框中输入提交信息,描述你的更改
  3. 点击对勾图标进行提交

也可以使用Git命令行:

# 暂存所有更改
git add .

# 提交更改
git commit -m "提交信息"

六、推送到Gitee远程仓库

6.1 设置远程仓库

如果是首次推送,需要先设置远程仓库:

# 添加远程仓库
git remote add origin 你的仓库地址

# 查看远程仓库
git remote -v

6.2 推送更改

完成本地提交后,可以将更改推送到Gitee:

  1. 在VS Code中,点击源代码管理面板右上角的"...",选择"推送"
  2. 或者使用Git命令行:
# 推送当前分支到远程
git push origin 分支名称

# 设置上游分支(首次推送时)
git push --set-upstream origin 分支名称

七、拉取远程更新

在团队协作中,我们需要定期拉取远程仓库的更新:

  1. 在VS Code中,点击源代码管理面板右上角的"...",选择"拉取"
  2. 或者使用Git命令行:
# 拉取远程更新
git pull

八、解决冲突

在多人协作中,代码冲突是不可避免的。当遇到冲突时:

  1. VS Code会高亮显示冲突的代码部分
  2. 你可以选择接受当前更改、接受传入更改或手动合并
  3. 解决完所有冲突后,需要再次暂存和提交

九、实用技巧

9.1 使用GitLens增强功能

GitLens插件提供了许多实用功能:

  • 行内 blame 信息:显示每行代码的最后修改人
  • 代码历史查看:查看文件或代码块的历史变更
  • 比较分支和提交:直观比较不同版本的代码差异

9.2 使用快捷键提高效率

以下是一些常用的Git相关快捷键:

  • Ctrl+Shift+G:打开源代码管理面板
  • Ctrl+Shift+P + Git: Commit:快速提交
  • Ctrl+Shift+P + Git: Push:快速推送

9.3 设置Git自动保存

为了避免忘记保存代码,可以在VS Code中开启自动保存:

  1. 点击"文件-自动保存",或使用快捷键Ctrl+Shift+P + "自动保存"
  2. 这样在你编辑代码时,VS Code会自动保存更改

十、总结

通过本文的介绍,相信你已经掌握了在VS Code中使用Gitee进行团队协作的基本流程。从环境配置到代码提交,再到解决冲突,这些技能将帮助你在实际开发中提高效率,更好地与团队成员协作。

记住,版本控制是一种习惯,需要在日常开发中不断实践和优化。希望本文对你有所帮助,祝你在Gitee协作中事半功倍!


延伸阅读:

最后,创作不易请允许我插播一则自己开发的小程序广告,感兴趣可以访问体验:

【「合图图」产品介绍】

  • 主要功能为:本地添加相册图片进行无限长图高清拼接,各种布局拼接等

  • 安全:无后台服务无需登录,全程设备本地运行,隐私100%安全;

  • 高效:自由布局+实时预览,效果所见即所得;

  • 高清:秒生高清拼图,一键保存相册。

  • 立即体验 →合图图 或微信小程序搜索「合图图」

如果觉得本文有用,欢迎点个赞👍和收藏⭐支持我吧!

借助trea开发浏览器自动滚动插件

作者 心安事随
2025年8月16日 12:24

github 地址: github.com/SunFei12345…

本次的开发借助的ai编辑器 是 trea, 借助的模型是gpt5

效果展示:

视频浏览

1755316891680.gif

掘金文章阅读

1755317097264.gif

背景

在浏览器一些文章的时候, 我们大部分都需要自己去滚动鼠标滑轮去往下看

在浏览视频的时候, 我们需要自己手动滚动浏览器查看想要点进去的视频封面。

比如我们在掘金看文章,我想要浏览器自己滚动,同时可以根据每个人的阅读速度,去调整滚动的速度。 这样就解放了双手。

亦或者在哔哩哔哩首页查看自己感兴趣的视频,我们就往下滑呀滑。 如果有这种自动滚动浏览器的插件我们就可以让页面自己滚动,找到感兴趣的视频, 我们就暂停点进去。

这虽然是一个小小的功能, 但是对我个人而言,感觉帮助还是蛮大的, 因为我也是因为这个困扰而想到开发这样的插件。虽然我没有去谷歌的扩展插件市场里面去搜索, 我想大概率是有这样的插件的。 不过目前有了ai, 开发的速度大大缩减,尤其是对于这种小工具/小项目/小插件的开发。 所以我更倾向自己借助ai去制作,不仅可以扩展自己的知识面,同时对于学习使用ai也是有帮助的。

需求设计阶段

我们需要将自己的需求和目的提出。让ai为我们准备好一个详细的prd文档,准备好之后我们再进行实施开发, 切记不要一上来就说我要开发一个xxx插件,请进行开发。 如果没有prd文档的约束, 后面大概率是返工修改等等。 所以在设计prd的时候我们就需要和ai进行沟通,包括哪些是需要的,哪些功能是不需要的, 实现的方式用什么,等等。

提示词:

我目前的需求是需要开发一个谷歌浏览器的插件, 主要用途就是 用户可以通过此插件, 自动滚动浏览器的屏幕, 同时支持调整滚动的速率, 支持暂停然后继续从当前暂停的位置继续滚动。 滚动到底部的时候,提示 并请求用户是否返回至顶部,或者重新滚动。 目前的需求是这样的。 我需要你设计出来一份详细的prd。 然后进行实施。

image.png

可见ai给出了一份详细的说明书,包括了

  • 产品概述
  • 用户和场景
  • 功能范围 包括了 必须实现的功能, 可选的实现功能, 应该添加的功能。
  • 交互和信息架构
  • ui表现
  • ...等等

也就是说,ai把我们考虑到的,没考虑到的全部涵盖的,同时给了一个基础版本涵盖的功能以及中期和后期的版本建设规划。

在第二轮对话中,我主要就这份prd 进行修改。

image.png

方案细化阶段

“方案细化”这个阶段,是为了把已经达成共识的方向转化为一套可执行、可验证、可交付的具体计划,从而把不确定性降到最低、把返工成本降到最低、把执行效率提到最高。

  • 目的(一句话) 将“做什么”和“怎么做”从概念层面落到文件、接口、状态与用例的细节层面,确保进入开发即刻可动手、少走弯路。

image.png

在这个阶段我们可以快读过一遍他给出的细化方案实施的每一步骤,如果有哪些不合适我们可以针对性的更改。这样大大减少了后期跟ai battle 的阶段。

方案实施阶段

紧接着就是根据细化方案去实施计划, 这时候我们可以全速驾驶让他根据细化方案去编写代码,这个过程也很快的。 然后编写完之后我们就可以快速去本地浏览器进行效果的验证, image.png

浏览器加载本地插件

加载扩展

  • 打开 Chrome → 地址栏输入 chrome://extensions → 打开“开发者模式”。
  • 点击“加载已解压的扩展程序”,选择文件夹:/Users/fl/Desktop/自动滚动浏览器插件开发。

验证

  • 打卡哔哩哔哩 或者 掘金首页, 然后右上角启用扩展,点击开始就会自动滚动页面了。

使用说明

安装

  1. 打开 Chrome 浏览器,输入 chrome://extensions/
  2. 开启"开发者模式"
  3. 点击"加载已解压的扩展程序"
  4. 选择插件文件夹即可

基本使用

悬浮面板

页面右下角会出现控制面板:

  • 播放/停止:开始或停止自动滚动
  • +/-:调节滚动速度
  • 顶部:返回页面顶部

快捷键

  • Alt+S:启动/停止滚动
  • Alt+P:暂停/恢复滚动
  • Alt+↑:加速滚动
  • Alt+↓:减速滚动

弹出窗口

点击浏览器工具栏的插件图标,可以:

  • 控制滚动状态
  • 精确调节速度
  • 打开设置页面

设置选项

在设置页面可以:

  • 调整默认滚动速度
  • 开启/关闭悬浮面板

常见问题

  • 悬浮面板不显示:刷新页面或重新加载插件
  • 按钮无反应:检查插件权限,重新安装
  • 滚动不流畅:调整速度到合适数值

提示:插件适用于阅读长文章、浏览社交媒体等场景。

Vue3第十八天,Vue3中的组件通信

2025年8月16日 11:44

Vue3组件通信和Vue2的区别:

  • 移出事件总线,使用mitt代替。
  • vuex换成了pinia
  • .sync优化到了v-model里面了。
  • $listeners所有的东西,合并到$attrs中了。
  • $children被砍掉了。

常见搭配形式:

image-20231119185900990.png

1. 【props】

概述:props是使用频率最高的一种通信方式,常用与 :父 ↔ 子

  • 父传子:属性值是非函数
  • 子传父:属性值是函数

父组件:

<template>
  <div class="father">
    <h3>父组件,</h3>
<h4>我的车:{{ car }}</h4>
<h4>儿子给的玩具:{{ toy }}</h4>
<Child :car="car" :getToy="getToy"/>
  </div>
</template>

<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";
// 数据
const car = ref('奔驰')
const toy = ref()
// 方法
function getToy(value:string){
toy.value = value
}
</script>

子组件

<template>
  <div class="child">
    <h3>子组件</h3>
<h4>我的玩具:{{ toy }}</h4>
<h4>父给我的车:{{ car }}</h4>
<button @click="getToy(toy)">玩具给父亲</button>
  </div>
</template>

<script setup lang="ts" name="Child">
import { ref } from "vue";
const toy = ref('奥特曼')

defineProps(['car','getToy'])
</script>

2. 【自定义事件】

  1. 概述:自定义事件常用于:子 => 父。
  2. 注意区分好:原生事件、自定义事件。
  • 原生事件:

    • 事件名是特定的(clickmosueenter等等)
    • 事件对象$event: 是包含事件相关信息的对象(pageXpageYtargetkeyCode
  • 自定义事件:

    • 事件名是任意名称
    • 事件对象$event: 是调用emit时所提供的数据,可以是任意类型!!!
  1. 示例:
<!--在父组件中,给子组件绑定自定义事件:-->
<Child @send-toy="toy = $event"/>

<!--注意区分原生事件与自定义事件中的$event-->
<button @click="toy = $event">测试</button>
//子组件中,触发事件:
this.$emit('send-toy', 具体数据)

3. 【mitt】

概述:与消息订阅与发布(pubsub)功能类似,可以实现任意组件间通信。

安装mitt

npm i mitt

新建文件:src\utils\emitter.ts

// 引入mitt 
import mitt from "mitt";

// 创建emitter
const emitter = mitt()

/*
  // 绑定事件
  emitter.on('abc',(value)=>{
    console.log('abc事件被触发',value)
  })
  emitter.on('xyz',(value)=>{
    console.log('xyz事件被触发',value)
  })

  setInterval(() => {
    // 触发事件
    emitter.emit('abc',666)
    emitter.emit('xyz',777)
  }, 1000);

  setTimeout(() => {
    // 清理事件
    emitter.all.clear()
  }, 3000); 
*/

// 创建并暴露mitt
export default emitter

接收数据的组件中:绑定事件、同时在销毁前解绑事件:

import emitter from "@/utils/emitter";
import { onUnmounted } from "vue";

// 绑定事件
emitter.on('send-toy',(value)=>{
  console.log('send-toy事件被触发',value)
})

onUnmounted(()=>{
  // 解绑事件
  emitter.off('send-toy')
})

【第三步】:提供数据的组件,在合适的时候触发事件

import emitter from "@/utils/emitter";

function sendToy(){
  // 触发事件
  emitter.emit('send-toy',toy.value)
}

注意这个重要的内置关系,总线依赖着这个内置关系

4.【v-model】

  1. 概述:实现 父↔子 之间相互通信。

  2. 前序知识 —— v-model的本质

    <!-- 使用v-model指令 -->
    <input type="text" v-model="userName">
    
    <!-- v-model的本质是下面这行代码 -->
    <input 
      type="text" 
      :value="userName" 
      @input="userName =(<HTMLInputElement>$event.target).value"
    >
    
  3. 组件标签上的v-model的本质::moldeValueupdate:modelValue事件。

<!-- 组件标签上使用v-model指令 -->
<AtguiguInput v-model="userName"/>

<!-- 组件标签上v-model的本质 -->
<AtguiguInput :modelValue="userName" @update:model-value="userName = $event"/>

AtguiguInput组件中:

<template>
  <div class="box">
    <!--将接收的value值赋给input元素的value属性,目的是:为了呈现数据 -->
<!--给input元素绑定原生input事件,触发input事件时,进而触发update:model-value事件-->
    <input 
       type="text" 
       :value="modelValue" 
       @input="emit('update:model-value',$event.target.value)"
    >
  </div>
</template>

<script setup lang="ts" name="AtguiguInput">
  // 接收props
  defineProps(['modelValue'])
  // 声明事件
  const emit = defineEmits(['update:model-value'])
</script>

4. 也可以更换value,例如改成abc

<!-- 也可以更换value,例如改成abc-->
<AtguiguInput v-model:abc="userName"/>

<!-- 上面代码的本质如下 -->
<AtguiguInput :abc="userName" @update:abc="userName = $event"/>

AtguiguInput组件中:

<template>
  <div class="box">
    <input 
       type="text" 
       :value="abc" 
       @input="emit('update:abc',$event.target.value)"
    >
  </div>
</template>

<script setup lang="ts" name="AtguiguInput">
  // 接收props
  defineProps(['abc'])
  // 声明事件
  const emit = defineEmits(['update:abc'])
</script>

5. 如果value可以更换,那么就可以在组件标签上多次使用v-model

<AtguiguInput v-model:abc="userName" v-model:xyz="password"/>

5.【$attrs 】

  1. 概述:$attrs用于实现当前组件的父组件,向当前组件的子组件通信(祖→孙)。

  2. 具体说明:$attrs是一个对象,包含所有父组件传入的标签属性。

    注意:$attrs会自动排除props中声明的属性(可以认为声明过的 props 被子组件自己“消费”了)

父组件:

<template>
  <div class="father">
    <h3>父组件</h3>
<Child :a="a" :b="b" :c="c" :d="d" v-bind="{x:100,y:200}" :updateA="updateA"/>
  </div>
</template>

<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";
let a = ref(1)
let b = ref(2)
let c = ref(3)
let d = ref(4)

function updateA(value){
a.value = value
}
</script>

子组件:

<template>
<div class="child">
<h3>子组件</h3>
<GrandChild v-bind="$attrs"/>
</div>
</template>

<script setup lang="ts" name="Child">
import GrandChild from './GrandChild.vue'
</script>

孙组件:

<template>
<div class="grand-child">
<h3>孙组件</h3>
<h4>a:{{ a }}</h4>
<h4>b:{{ b }}</h4>
<h4>c:{{ c }}</h4>
<h4>d:{{ d }}</h4>
<h4>x:{{ x }}</h4>
<h4>y:{{ y }}</h4>
<button @click="updateA(666)">点我更新A</button>
</div>
</template>

<script setup lang="ts" name="GrandChild">
defineProps(['a','b','c','d','x','y','updateA'])
</script>

6. 【refsrefs、parent】

  1. 概述:

    • $refs用于 :父→子。
    • $parent用于:子→父。
  2. 原理如下:

    属性 说明
    $refs 值为对象,包含所有被ref属性标识的DOM元素或组件实例。
    $parent 值为对象,当前组件的父组件实例对象。

7. 【provide、inject】

  1. 概述:实现祖孙组件直接通信

  2. 具体使用:

    • 在祖先组件中通过provide配置向后代组件提供数据
    • 在后代组件中通过inject配置来声明接收数据
  3. 具体编码:

    【第一步】父组件中,使用provide提供数据

<template>
  <div class="father">
    <h3>父组件</h3>
    <h4>资产:{{ money }}</h4>
    <h4>汽车:{{ car }}</h4>
    <button @click="money += 1">资产+1</button>
    <button @click="car.price += 1">汽车价格+1</button>
    <Child/>
  </div>
</template>

<script setup lang="ts" name="Father">
  import Child from './Child.vue'
  import { ref,reactive,provide } from "vue";
  // 数据
  let money = ref(100)
  let car = reactive({
    brand:'奔驰',
    price:100
  })
  // 用于更新money的方法
  function updateMoney(value:number){
    money.value += value
  }
  // 提供数据
  provide('moneyContext',{money,updateMoney})
  provide('car',car)
</script>

注意:子组件中不用编写任何东西,是不受到任何打扰的

【第二步】孙组件中使用inject配置项接受数据。

<template>
  <div class="grand-child">
    <h3>我是孙组件</h3>
    <h4>资产:{{ money }}</h4>
    <h4>汽车:{{ car }}</h4>
    <button @click="updateMoney(6)">点我</button>
  </div>
</template>

<script setup lang="ts" name="GrandChild">
  import { inject } from 'vue';
  // 注入数据
 let {money,updateMoney} = inject('moneyContext',{money:0,updateMoney:(x:number)=>{}})
  let car = inject('car')
</script>

8. 【slot】

1. 默认插槽

default_slot.png

父组件中:
        <Category title="今日热门游戏">
          <ul>
            <li v-for="g in games" :key="g.id">{{ g.name }}</li>
          </ul>
        </Category>
子组件中:
        <template>
          <div class="item">
            <h3>{{ title }}</h3>
            <!-- 默认插槽 -->
            <slot></slot>
          </div>
        </template>

2. 具名插槽

父组件中:
        <Category title="今日热门游戏">
          <template v-slot:s1>
            <ul>
              <li v-for="g in games" :key="g.id">{{ g.name }}</li>
            </ul>
          </template>
          <template #s2>
            <a href="">更多</a>
          </template>
        </Category>
子组件中:
        <template>
          <div class="item">
            <h3>{{ title }}</h3>
            <slot name="s1"></slot>
            <slot name="s2"></slot>
          </div>
        </template>

3. 作用域插槽

  1. 理解:数据在组件的自身,但根据数据生成的结构需要组件的使用者来决定。(新闻数据在News组件中,但使用数据所遍历出来的结构由App组件决定)
  2. 具体编码:
父组件中:
      <Game v-slot="params">
      <!-- <Game v-slot:default="params"> -->
      <!-- <Game #default="params"> -->
        <ul>
          <li v-for="g in params.games" :key="g.id">{{ g.name }}</li>
        </ul>
      </Game>

子组件中:
      <template>
        <div class="category">
          <h2>今日游戏榜单</h2>
          <slot :games="games" a="哈哈"></slot>
        </div>
      </template>

      <script setup lang="ts" name="Category">
        import {reactive} from 'vue'
        let games = reactive([
          {id:'asgdytsa01',name:'英雄联盟'},
          {id:'asgdytsa02',name:'王者荣耀'},
          {id:'asgdytsa03',name:'红色警戒'},
          {id:'asgdytsa04',name:'斗罗大陆'}
        ])
      </script>

我们下次有缘再见!

第二章 虎牢关前初试Composition,吕布持v-model搦战

2025年8月16日 11:28

上回说到桃园三英,以reactive义旗初燃响应之火。未及旬日,消息传遍十八路诸侯:董卓挟旧框架jQuery,虎踞虎牢关,其义子吕布手持方天画戟,戟上双锋刻着“v-model”二字,号称“双向绑定第一猛将”,凡与之交锋者,模板与数据顷刻错乱,士卒(开发者)叫苦不迭。诸侯震惧,聚于酸枣大营,共议破敌之策。

玄德谓关、张曰:“吕布之势,在于旧双向绑定蛮横:一处改值,处处牵连,调试如坠五里雾。吾等新得Vue 3利器,可趁此关试其锋芒。”于是三人随公孙瓒军,星夜抵虎牢关下。

翌日黎明,鼓角齐鸣。吕布匹马出阵,戟指诸侯大喝:“谁敢与我斗绑定!”诸侯阵中,一将应声而出,乃江东孙坚,旧用Angular.js,被吕布一戟挑落马下,数据流当场断链。诸侯失色。

玄德回顾云长:“二弟,汝可出战?”云长丹凤眼微睁,提刀而出,却非青龙偃月,而是一柄新铸长刀,名曰<script setup>。刀背暗藏三大新纹:

  1. 纹一:ref()——化普通值为响应利刃;
  2. 纹二:computed()——凝衍生数据为刀罡;
  3. 纹三:watchEffect()——布追踪暗劲,敌一动则我即知。

云长横刀立马,朗声道:“吕布小儿,敢接我Composition刀法否?”

吕布大笑,挥戟直取。云长举刀迎敌,只见刀光闪处,代码如诗:

<!-- 虎牢关·关云长挑战牌.vue -->
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'

const luBuHP = ref(100)          // 吕布血条
const guanYuATK = ref(30)        // 关羽攻击
const isCritical = computed(() => luBuHP.value < 50)
watchEffect(() => {
  if (isCritical.value) console.warn('吕布进入红血,狂暴模式开启!')
})
</script>

<template>
  <div>
    <p>吕布剩余血量:{{ luBuHP }}</p>
    <button @click="luBuHP -= guanYuATK">青龙斩</button>
  </div>
</template>

刀戟相交,吕布顿觉旧法迟钝:每次v-model改动,需层层触发$digest,而云长刀法轻灵,仅在必要处精准更新,DOM重绘大减。三十合后,吕布戟法渐乱。

翼德见云长占上风,大吼一声,挺矛跃马而出。其矛名Teleport,一矛刺出,竟将“

”元素瞬息移至关外战场中央,避开层层嵌套之DOM重围,令吕布措手不及。

玄德亦催马上前,袖中飞出一面小旗,旗上书“Suspense”。旗展处,诸侯军阵忽现异步大军:先遣async setup()轻骑诱敌,随后<Suspense>大军稳压阵脚,待数据从远域(API)归来,三军齐发,吕布旧部顿时崩溃。

吕布见势不妙,拨马欲走。云长刀锋一转,大喝:“留下v-model!”刀落处,吕布戟上“双向绑定”四字应声而碎,化作漫天光屑。诸侯军乘势掩杀,虎牢关大门轰然洞开。

战后,酸枣大营庆功。曹操把盏谓玄德曰:“今日之战,方知Vue 3新特性之利:
<script setup>简军书(样板代码减半);
ref & reactive分兵权(原始与对象各得其所);
computedwatchEffect如伏兵暗哨,料敌先机;
Teleport奇兵突袭,解定位之困;
Suspense则整饬异步之乱,令军容肃整。

有此五利,何愁旧框架不破?”

玄德谦逊答曰:“皆赖众志。然董卓未灭,jQuery余孽犹在,吾等当整军向西,直驱长安。”

众人齐应。夜色下,营火映照着一面新旗——旗上赫然是Vue 3的Logo,而下方绣着一行小字:

“Composition API · 破釜沉舟”

(第二章完)

——下回《凤仪亭密谋自定义ref,貂蝉夜探shallowReactive》

第一章 桃园灯火初燃,响应义旗始揭

2025年8月16日 11:24

却说中平元年,黄巾大乱,页面失序,交互崩坏。时有涿郡涿县义士刘备字玄德,胸怀仁道,常叹 DOM 操作之繁;关羽字云长,力能扛鼎,恨 reflow 之劳形;张飞字翼德,声若巨雷,苦 repaint 之伤神。三人心念苍生,俱欲收拾旧山河,重整乾坤之渲染。

是夜,桃园深处,月色如银。三人焚香再拜,誓以 Vue 3 为号,举响应式义旗。刘备执defineReactive为剑,关羽握ref作刀,张飞抡reactive成矛。金兰结义,共立宏愿:
“自此以后,凡我兄弟,同写单文件组件,同守 Composition API,同赴前端沙场,生死与共,不可背弃!”

誓毕,刘备展卷,出一物示二人,乃《setup()》秘策一卷。卷首云:
“夫响应之道,先立 state,后衍 effect;state 者,民生之本,effect 者,治世之干。”
关羽、张飞拜受,顿首再拜。于是三英于桃园之中,点燃第一簇数据之火——

// 桃园结义·state.ts
import { reactive } from 'vue'

export const peachGarden = reactive({
  brothers: ['刘备', '关羽', '张飞'],
  oath: '上报国家,下安黎庶,同生共死,永不背义'
})

火光照处,页面微颤,旧日静态之 HTML 忽生涟漪。张飞惊曰:“异哉!我但改一字,视图即随动,莫非天命?”
刘备笑曰:“非天命也,Proxy 之力耳。凡入reactive者,皆录于 WeakMap,牵一发而动全身,此即‘响应’二字真谛!”

关羽抚髯而思:“既有响应之兵,尚缺调度之帅。来日当筑effect营寨,使数据之兵随帅旗而行,无令散乱。”

三人言谈未尽,忽闻远处鼓角之声——黄巾残党jQuery余孽,正聚众欲复辟直接操作 DOM 之旧制。刘备拔剑而起:“兄弟,随我出村,初试响应锋芒!”

于是桃园灯火未灭,三骑已扬尘而去。前端乱世,自此拉开序幕。后人有诗赞曰:

桃园一火照前端,
Proxy 初开响应天。
自此 DOM 随令转,
三分代码见真源。

(第一章完)

——下回《虎牢关前初试Composition,吕布持双向绑定搦战》

童年中的坦克大战(Trae版)

2025年8月16日 11:08

前言

今天来还原童年记忆中的坦克大战,让Trae用代码实现这个游戏的核心功能。

这个游戏的核心功能

  1. 玩家控制一个坦克,通过键盘方向键移动坦克。
  2. 玩家可以发射子弹,子弹会向坦克的方向移动。
  3. 敌人的坦克会随机移动,也会发射子弹。
  4. 玩家和敌人的坦克都有自己的生命值,当生命值减少到0时,坦克会被销毁。
  5. 游戏会有一个倒计时,当倒计时结束时,游戏结束,玩家需要在规定时间内消灭所有敌人的坦克。
  6. 游戏会有一个得分系统,玩家每消灭一个敌人的坦克,就会获得一定的分数。 image.png 先把这个核心逻辑发给Trae,看看他完成的是不是你想要的童年记忆

显然不是,页面ui有点过于粗糙,这次直接让他生成小霸王里面的坦克大战,他应该知道是怎么样的

image.png

image.png

这效果还是有点像,毕竟我们没有资源文件,这样的完成度已经不错了,我们再让他把地图画大一点,坦克不能重叠

image.png

主要还是要把地图画精美一点

image.png

这次就很不错

image.png

Trae代码解读

首先是关卡的数组,根据这个生成坦克和墙壁

image.png

添加钢墙,小时候是不是经常把基地的墙拆了,然后换上钢墙,这样我们就可以保证不死,哈哈,从而轻松的通关

const steelWalls = [
 [[5,5],[7,5]],
 [[4,6],[5,6],[6,6]],
 [[6,7],[7,7],[8,7]],
 [[3,8],[4,8]],
 [[9,8],[10,8]],
 [[5,9],[6,9]]
];

两名玩家的出生点,以及坦克的颜色和编号作为游戏中的区别

createPlayers() {
        this.players = [
            new Player(8 * this.TILE_SIZE, 18 * this.TILE_SIZE, '#00ff00', 1),
            new Player(12 * this.TILE_SIZE, 18 * this.TILE_SIZE, '#ffff00', 2)
        ];
        // 设置出生点用于重生
        this.players.forEach(player => {
            player.startX = player.x;
            player.startY = player.y;
        });
    }

生成新敌人,如果需要的话,并更新计时器,源源不断的生成敌人

this.enemySpawnTimer++;
        if (this.enemySpawnTimer >= this.enemySpawnDelay && this.enemies.filter(e => e.alive).length < 4) {
            this.spawnEnemy();
            this.enemySpawnTimer = 0;
        }

子弹与地图碰撞检测,子弹与敌人碰撞检测,子弹与基地碰撞检测,碰撞了就把墙和敌人消失,这样就可以新生成敌人

image.png

总结

1、这个游戏的核心功能包括玩家控制坦克移动、发射子弹,敌人的坦克随机移动和发射子弹,坦克的生命值和得分系统,以及倒计时结束时的游戏结束。通过这些功能,玩家可以在规定时间内消灭所有敌人的坦克,获得高分。

2、通过绘制钢墙,玩家可以保护自己的基地,防止敌人的子弹击中基地。同时,钢墙也可以作为游戏的地图边界,防止敌人的坦克超出地图范围。主要还是还原童年的骚操作,可以让自己在游戏保证不死的通过游戏关卡。都是满满的童年回忆。

你是否也玩过这个游戏呢?

Flutter进阶:高内存任务的动态并发执行完美实现

作者 SoaringHeart
2025年8月16日 10:58

一、需求来源

flutter 项目中会遇到多文件上传或者下载之类的批量执行任务就需要控制并发数量(避免内存爆炸或者数据传输效率过差),今天灵光一闪,实现一个极简的,分享给大家。

内存爆炸: 一般表现为app闪退。

数据传输效率过差:一般表现为并发数量较多时,每个任务的速度都很慢,减少并发数可以显著提升速度(上传下载同理)。

二、使用示例

假设任务总数是 10,最大并发是 3,每个任务耗时 1 秒完成,执行过程如下:

》执行任务0
》执行任务1
》执行任务2
》任务0 完成,3开始
》任务1 完成,4开始
》任务2 完成,5开始
》任务3 完成,6开始
》任务4 完成,7开始
》任务5 完成,8开始
》任务6 完成,9开始
》任务7 完成
》任务8 完成
》任务9 完成

Simulator Screenshot - iPhone 16 - 2025-08-06 at 10.27.29.png

三、源码

import 'dart:collection';

import 'package:flutter/material.dart';
import 'package:flutter_templet_project/extension/dlog.dart';
import 'package:get/get.dart';

class ConcurrentExecutorDemo extends StatefulWidget {
  const ConcurrentExecutorDemo({
    super.key,
    this.arguments,
  });

  final Map<String, dynamic>? arguments;

  @override
  State<ConcurrentExecutorDemo> createState() => _ConcurrentExecutorDemoState();
}

class _ConcurrentExecutorDemoState extends State<ConcurrentExecutorDemo> {
  final scrollController = ScrollController();

  Map<String, dynamic> arguments = Get.arguments ?? <String, dynamic>{};

  /// 任务数
  var taskCount = 10;

  /// 并发数量
  var maxConcurrent = 3;

  /// 执行描述
  final descVN = ValueNotifier("");

  String get timeStr => DateTime.now().toString();

  @override
  void didUpdateWidget(covariant ConcurrentExecutorDemo oldWidget) {
    super.didUpdateWidget(oldWidget);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("$widget"),
      ),
      body: buildBody(),
    );
  }

  Widget buildBody() {
    return Scrollbar(
      controller: scrollController,
      child: SingleChildScrollView(
        controller: scrollController,
        child: Column(
          children: [
            Text("并发执行(总数:$taskCount, 最大并发数: $maxConcurrent):"),
            ElevatedButton(onPressed: onTest, child: Text("开始执行")),
            ValueListenableBuilder(
              valueListenable: descVN,
              builder: (context, value, child) {
                return Text(value);
              },
            ),
          ],
        ),
      ),
    );
  }

  Future<void> onTest() async {
    descVN.value = "";

    final executor = ConcurrentExecutor(maxConcurrent: maxConcurrent);

    for (var i = 0; i < taskCount; i++) {
      final id = i;
      executor.add(() async {
        DLog.d('Start $id');
        descVN.value += "[$timeStr]Start $id\n";
        await Future.delayed(Duration(seconds: 1));
        DLog.d('Done $id');
        descVN.value += "[$timeStr]Done $id\n";
      });
    }

    await executor.waitForEmpty();
    DLog.d('All tasks complete');
    descVN.value += "[$timeStr]All tasks complete";
  }
}

/// 并发执行器
class ConcurrentExecutor {
  ConcurrentExecutor({this.maxConcurrent = 3});

  final int maxConcurrent;
  final Queue<Future<void> Function()> _taskQueue = Queue();
  int _running = 0;

  void add(Future<void> Function() task) {
    _taskQueue.add(task);
    _tryExecuteNext();
  }

  void _tryExecuteNext() {
    while (_running < maxConcurrent && _taskQueue.isNotEmpty) {
      final task = _taskQueue.removeFirst();
      _running++;
      task().whenComplete(() {
        _running--;
        _tryExecuteNext();
      });
    }
  }

  Future<void> waitForEmpty() async {
    while (_taskQueue.isNotEmpty || _running > 0) {
      await Future.delayed(Duration(milliseconds: 50));
    }
  }
}

最后、总结

1、本质上任务管理类实现一个任务队列,根据当前任务执行情况,在限定最大并发数的前提开启新任务,直到清空队列任务的动态过程管理。多用于任务(可能会占用高内存)且多数量(可能导致内存爆炸),和线程池中线程复用是同一个原理。也算是软件开发中具体场景的唯一真解,属于中高阶软件开发展必须掌握的技能。

2、典型场景:批量音视频上传下载,云盘同步等

github

flutter滚动视图之ScrollPositionAlignmentPolicy、ScrollPosition源码解析(二)

作者 Nicholas68
2025年8月16日 10:45

ScrollPositionAlignmentPolicy

/// 在应用 [ScrollPosition.ensureVisible] 的 `alignment` 参数时使用的策略。
enum ScrollPositionAlignmentPolicy {
  /// 使用 [ScrollPosition.ensureVisible] 的 `alignment` 参数来决定
  /// 目标对象在滚动后如何对齐。
  explicit,

  /// 找到滚动容器的底部边界,如果有需要则滚动容器,以保证对象的底部可见。
  ///
  /// 例如,找到滚动容器的底部边界。如果目标项的底部在容器的底部边界之下,
  /// 则滚动使该目标项的底部正好出现在容器的可见区域内。
  /// 如果该目标项已经完全可见,则不进行滚动。
  keepVisibleAtEnd,

  /// 找到滚动容器的顶部边界,如果有需要则滚动容器,以保证对象的顶部可见。
  ///
  /// 例如,找到滚动容器的顶部边界。如果目标项的顶部在容器的顶部边界之上,
  /// 则滚动使该目标项的顶部正好出现在容器的可见区域内。
  /// 如果该目标项已经完全可见,则不进行滚动。
  keepVisibleAtStart,
}

是一个 枚举,主要用于控制 ScrollPosition.ensureVisible 在滚动时如何对齐目标 Widget。

1. explicit

  • 行为:完全依赖传入的 alignment 参数(0.0 表示顶部对齐,1.0 表示底部对齐,0.5 表示居中等)。
  • 应用场景:你希望 自定义精确的对齐方式

2. keepVisibleAtEnd

  • 行为:如果目标 Widget 的 底部超出容器的可见范围,就滚动让它的底部正好显示在容器底部;
    如果目标 Widget 已经完整可见,则不滚动。
  • 应用场景:适合 聊天窗口 这类场景,新消息进来时保持在底部。

3. keepVisibleAtStart

  • 行为:如果目标 Widget 的 顶部在容器上方不可见,就滚动让它的顶部正好显示在容器顶部;
    如果目标 Widget 已经完整可见,则不滚动。
  • 应用场景:适合需要保持 列表头部元素可见 的情况,比如目录导航。

ScrollPosition

ScrollPosition 就是 记录和管理滚动视图当前滚到哪、能滚到哪、怎么滚的对象,它保存了偏移量、边界、滚动状态,并能通知监听器变化。

  • 存数据:保存当前滚动位置(pixels)、最大/最小能滚多远(maxScrollExtent / minScrollExtent)。

  • 管滚动:配合 physics 决定滚动方式(比如弹性、惯性)。

  • 控行为:通过 activity 表示现在是拖拽、惯性还是静止。

  • 能监听Listenable,随时通知你「滚动发生了」。

  • 配套用:常和 ScrollControllerNotificationListener 一起使用。

/// 确定在滚动视图中内容的哪一部分是可见的。
///
/// [pixels] 值决定了滚动视图的滚动偏移量,用于选择显示内容的哪一部分。
/// 当用户滚动视口时,这个值会发生变化,从而改变显示的内容。
///
/// [ScrollPosition] 会对滚动应用 [physics],并保存 [minScrollExtent] 和 [maxScrollExtent]。
///
/// 滚动由当前的 [activity] 控制,它是通过 [beginActivity] 设置的。
/// [ScrollPosition] 本身并不会启动任何 activity。
/// 相反,具体的子类(如 [ScrollPositionWithSingleContext])通常会在响应用户输入
/// 或 [ScrollController] 的指令时启动 activity。
///
/// 该对象是一个 [Listenable],当 [pixels] 发生变化时会通知监听器。
///
/// {@template flutter.widgets.scrollPosition.listening}
/// ### 获取滚动信息
///
/// 有几种方式可以获取有关滚动和可滚动组件的信息,
/// 但它们提供的信息类型各不相同,
/// 包括滚动行为、位置以及 [Viewport] 的尺寸等。
///
/// [ScrollController] 是一个 [Listenable]。
/// 当任意一个附加到它的 [ScrollPosition] 通知其监听器时,
/// 它也会通知自己的监听器(比如在滚动发生时)。
/// 这和使用 [ScrollNotification] 类型的 [NotificationListener]
/// 来监听滚动位置变化非常相似,但两者不同之处在于:
///
/// - [NotificationListener] 会提供有关“滚动活动”的信息;  
/// - [ScrollController] 则可以直接访问滚动位置对象本身。  
///
/// 另外,监听 [ScrollNotification] 还可以细分为监听其特定子类,
/// 比如 [UserScrollNotification]。
///
/// {@tool dartpad}
/// 这个示例展示了使用 [ScrollController] 与使用 [ScrollNotification] 类型的
/// [NotificationListener] 来监听滚动的区别。
/// 切换 [Radio] 按钮可以在两种方式间切换。
/// 使用 [ScrollNotification] 可以获取滚动活动的详细信息以及 [ScrollPosition] 的指标,
/// 但不能直接获取滚动位置对象本身;
/// 使用 [ScrollController] 则可以直接访问位置对象。
/// 这两种方式都只会在“滚动行为发生时”触发。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_controller_notification.0.dart**
/// {@end-tool}
///
/// 注意:  
/// [ScrollController] 不会在附加到它的 [ScrollPosition] 列表发生变化时通知监听器。  
/// 如果需要监听滚动位置的附加和分离,可以使用 [ScrollController.onAttach] 和
/// [ScrollController.onDetach] 方法。  
/// 这在 [Scrollable] 的 build 方法中创建滚动位置时,
/// 想要为其 [ScrollPosition.isScrollingNotifier] 添加监听器时也很有用。
///
/// 当一个滚动位置刚附加上时,
/// 它的 [ScrollMetrics](例如 [ScrollMetrics.maxScrollExtent])还不可用,  
/// 因为这些值要等到 [Scrollable] 完成布局并计算内容范围后才会确定。  
/// 可以通过 [ScrollPosition.hasContentDimensions] 来判断指标是否已可用,  
/// 或者使用 [ScrollMetricsNotification](后面会详细讨论)。
///
/// {@tool dartpad}
/// 这个示例展示了如何通过 [ScrollController.onAttach]
/// 为 [ScrollPosition.isScrollingNotifier] 添加监听器。  
/// 它用于在滚动发生时改变 [AppBar] 的颜色。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_controller_on_attach.0.dart**
/// {@end-tool}
///
/// #### 从不同的上下文中获取
///
/// 当需要在“滚动组件内部的上下文”中获取滚动信息时,
/// 可以使用 [Scrollable.of] 获取 [ScrollableState],
/// 然后通过 [ScrollableState.position] 获取 [ScrollPosition]。  
/// 这个位置对象就是附加在 [ScrollController] 上的那个。
///
/// 当需要在“滚动组件外部的上下文”中获取滚动信息时,使用 [ScrollNotificationObserver]。  
/// 例如 [AppBar] 的“滚动置底效果”就是这样实现的。  
/// 因为 [Scaffold.appBar] 与 [Scaffold.body] 属于不同的子树,  
/// 滚动通知不会冒泡到 app bar。  
/// 可以通过 [ScrollNotificationObserverState.addListener] 监听在当前上下文之外发生的滚动通知。
///
/// #### 尺寸变化
///
/// 需要注意的是:  
/// 监听 [ScrollController] 或 [ScrollPosition] **不会**在滚动指标([ScrollMetrics])发生变化时触发,  
/// 比如窗口大小改变导致 [Viewport] 尺寸或可滚动范围发生变化。  
/// 如果需要监听滚动指标的变化,应使用 [ScrollMetricsNotification]。  
/// 与 [ScrollNotification] 不同,  
/// [ScrollMetricsNotification] 并不与“滚动活动”相关,  
/// 而是与滚动区域的尺寸变化有关(例如窗口大小)。
///
/// {@tool dartpad}
/// 这个示例展示了当 `windowSize` 改变时,
/// 如何派发一个 [ScrollMetricsNotification]。  
/// 点击浮动按钮可以增加可滚动窗口的大小。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_metrics_notification.0.dart**
/// {@end-tool}
/// {@endtemplate}
///
/// ## 继承 ScrollPosition
///
/// 随着时间推移,一个 [Scrollable] 可能会有多个不同的 [ScrollPosition] 对象。  
/// 例如,当 [Scrollable.physics] 的类型发生变化时,  
/// [Scrollable] 会使用新的 physics 创建一个新的 [ScrollPosition]。  
/// 为了将旧实例的状态转移到新实例,子类需要实现 [absorb] 方法(详见 [absorb])。  
///
/// 此外,子类在 [userScrollDirection] 值变化时还需要调用 [didUpdateScrollDirection]。
///
/// 参见:
///
///  * [Scrollable]:使用 [ScrollPosition] 来决定显示内容的哪一部分。  
///  * [ScrollController]:可与 [ListView]、[GridView] 等可滚动组件一起使用,用于控制 [ScrollPosition]。  
///  * [ScrollPositionWithSingleContext]:最常用的 [ScrollPosition] 具体子类。  
///  * [ScrollNotification] 和 [NotificationListener]:在不使用 [ScrollController] 的情况下监听滚动位置。  
abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {}

ScrollPosition

  /// 创建一个对象,用来确定滚动视图中哪一部分内容是可见的。
  ScrollPosition({
    required this.physics,        // 滚动物理特性(决定滚动行为,比如惯性、回弹等)
    required this.context,        // 上下文信息(通常是 Scrollable 的 BuildContext)
    this.keepScrollOffset = true, // 是否保持滚动偏移量(通常用于恢复滚动位置)
    ScrollPosition? oldPosition,  // 旧的滚动位置对象,用来在重建时继承状态
    this.debugLabel,              // 调试标签
  }) {
    if (oldPosition != null) {
      absorb(oldPosition);        // 如果有旧的滚动位置,吸收它的状态
    }
    if (keepScrollOffset) {
      restoreScrollOffset();      // 如果需要保持滚动偏移,就尝试恢复滚动位置
    }
  }

}

当创建一个 ScrollPosition 时,它会做三件事:

  1. 设置基础属性:保存滚动物理特性(physics)、上下文(context)、是否保持偏移(keepScrollOffset)等。
  2. 继承旧状态:如果传入了 oldPosition,会调用 absorb(oldPosition),把旧的滚动状态(如 pixels、方向等)转移过来。
  3. 恢复滚动偏移:如果 keepScrollOffset = true,会尝试调用 restoreScrollOffset() 恢复之前的滚动位置(比如应用重建后保持列表滚动到原来的位置)。

假设有一个 ListView,滑到了中间,然后因为主题切换或屏幕旋转导致 Widget 重建:

  • Flutter 会创建一个新的 ScrollPosition
  • 它会把旧的 ScrollPosition 的状态吸收过来(所以列表不会跳到顶部)。
  • 如果设置了 keepScrollOffset = true,它还会自动恢复到之前的滚动位置。

physics、context、keepScrollOffset、minScrollExtent、maxScrollExtent

  /// 滚动位置应该如何响应用户输入。
  ///
  /// 例如:决定当用户停止拖动滚动视图后,
  /// 该组件应该如何继续执行动画。
  final ScrollPhysics physics;

  /// 滚动发生的上下文。
  ///
  /// 通常由 [ScrollableState] 实现。
  final ScrollContext context;

  /// 是否使用 [PageStorage] 保存当前滚动偏移,并在该滚动位置的
  /// 可滚动组件被重新创建时恢复它。
  ///
  /// 另见:
  ///
  ///  * [ScrollController.keepScrollOffset] 和 [PageController.keepPage],
  ///    它们会创建滚动位置并初始化这个属性。
  // TODO(goderbauer): 当状态恢复支持 PageStorage 的所有功能时,弃用这个属性。
  final bool keepScrollOffset;

  /// 一个用于 [toString] 输出的标签。
  ///
  /// 主要用于在调试输出中帮助区分不同的动画控制器实例。
  final String? debugLabel;

  @override
  double get minScrollExtent => _minScrollExtent!;
  double? _minScrollExtent;   // 最小可滚动范围

  @override
  double get maxScrollExtent => _maxScrollExtent!;
  double? _maxScrollExtent;   // 最大可滚动范围

  @override
  bool get hasContentDimensions => _minScrollExtent != null && _maxScrollExtent != null;
  // 是否已经有内容的滚动范围(即 min/max 都已确定)

  /// 在单帧内 [forcePixels] 改变时,额外添加的速度。
  ///
  /// 这个值和当前 [activity] 的 [ScrollActivity.velocity] 一起
  /// 用于 [recommendDeferredLoading],来询问 [physics] 是否需要延迟加载。
  ///
  /// 这是因为一次 [forcePixels] 调用可能对应的 [ScrollActivity] 速度为 0,
  /// 但实际上滚动视图瞬间从当前位置跳到一个很远的位置。
  /// 调用方需要考虑这种情况。
  ///
  /// 举例:
  /// 如果当前滚动位置在 5000 像素,我们 [jumpTo] 到 0(回到顶部),
  /// 则隐含速度为 -5000,而 `activity.velocity` 为 0。
  /// 在这个跳转过程中,可能会跨过大量资源消耗大的 Widget,
  /// 它们应该避免执行不必要的工作。
  double _impliedVelocity = 0;

  @override
  double get pixels => _pixels!;
  double? _pixels;   // 当前滚动偏移量

  @override
  bool get hasPixels => _pixels != null;   // 是否有有效的滚动偏移

  @override
  double get viewportDimension => _viewportDimension!;
  double? _viewportDimension;   // 视口(可见区域)的尺寸

  @override
  bool get hasViewportDimension => _viewportDimension != null; 
  // 是否已经有视口尺寸

  /// 是否已经有 [viewportDimension]、[minScrollExtent]、[maxScrollExtent]、
  /// [outOfRange] 和 [atEdge] 等值。
  ///
  /// 在第一次调用 [applyNewDimensions] 之前设置为 true。
  bool get haveDimensions => _haveDimensions;
  bool _haveDimensions = false;

  /// 该位置上的可滚动组件是否应该吸收指针事件。
  ///
  /// 该值取决于当前的 [ScrollActivity],
  /// 决定滚动视图或其子元素是否还能接收额外的触摸输入。
  /// 
  /// 比如:在 [BouncingScrollPhysics] 允许越界滚动时,
  /// 当滚动视图从越界状态回弹时,
  /// 子组件仍然可以接收指针事件。
  bool get shouldIgnorePointer => !outOfRange && (activity?.shouldIgnorePointer ?? true);

  • 核心属性

    • physics 👉 滚动规则(决定怎么滚)。
    • context 👉 滚动发生的环境。
    • keepScrollOffset 👉 要不要保存和恢复滚动位置。
    • debugLabel 👉 调试用的名字。
  • 滚动范围

    • minScrollExtent / maxScrollExtent 👉 最小、最大可滚动位置。
    • hasContentDimensions 👉 是否已经知道内容的滚动范围。
  • 滚动状态

    • pixels 👉 当前滚动偏移量。
    • viewportDimension 👉 可见窗口的大小。
    • haveDimensions 👉 是否已经知道了完整的滚动尺寸信息。
  • 特殊逻辑

    • _impliedVelocity 👉 处理「瞬间跳跃」时的隐含速度。
    • shouldIgnorePointer 👉 决定滚动时,子组件是否还能响应触摸事件。

absorb

  /// 从给定的 [ScrollPosition] 中接管所有当前可用的状态。
  ///
  /// 如果构造函数传入了 `oldPosition`,就会调用此方法。
  /// `other` 参数的 [runtimeType] 可能和当前对象不同。
  ///
  /// 这个方法可能会破坏另一个 [ScrollPosition] 的状态。
  /// 调用后,另一个对象必须立即被销毁(在相同的调用栈中,
  /// 在 microtask 执行前,由调用当前构造函数的代码负责销毁)。
  ///
  /// 如果旧的 [ScrollPosition] 对象和当前对象的 [runtimeType] 不同,
  /// 那么会在新接管的 [ScrollActivity] 上调用 [ScrollActivity.resetActivity]。
  ///
  /// ## 重写注意事项
  ///
  /// 如果子类要重写此方法:
  /// - 必须在设置任何和滚动指标或活动相关的状态后,调用 `super.absorb`,
  ///   因为此方法可能会重启 activity,而滚动活动通常依赖这些指标。  
  /// - 如果无法吸收另一个对象的 activity,可能需要手动启动一个 [IdleScrollActivity]。  
  /// - 如果子类本身充当了 [ScrollActivityDelegate],可能需要更新已吸收的
  ///   scroll activities 的 delegate。  


void absorb(ScrollPosition other) {
  assert(other.context == context);     // 确保两个位置的上下文一致
  assert(_pixels == null);              // 确保当前还没有设置滚动位置

  // 如果旧位置有内容范围,继承过来
  if (other.hasContentDimensions) {
    _minScrollExtent = other.minScrollExtent;
    _maxScrollExtent = other.maxScrollExtent;
  }

  // 如果旧位置有像素偏移,继承过来
  if (other.hasPixels) {
    _pixels = other.pixels;
  }

  // 如果旧位置有视口尺寸,继承过来
  if (other.hasViewportDimension) {
    _viewportDimension = other.viewportDimension;
  }

  // 接管 activity
  assert(activity == null);
  assert(other.activity != null);
  _activity = other.activity;
  other._activity = null;               // 旧的 activity 被“掏空”

  // 如果两个对象的类型不同,需要重置 activity
  if (other.runtimeType != runtimeType) {
    activity!.resetActivity();
  }

  // 更新 context 和 isScrollingNotifier 的状态
  context.setIgnorePointer(activity!.shouldIgnorePointer);
  isScrollingNotifier.value = activity!.isScrolling;
}

absorb 就是 把旧的 ScrollPosition 的状态(滚动范围、偏移、视口大小、activity 等)吸收过来,然后让旧的对象“失效”,再用新的继续工作。

场景举例:

  • 你在 ListView 里滚动到一半。
  • 因为某些原因(比如 physics 改变)需要创建一个新的 ScrollPosition
  • 新的对象会调用 absorb(oldPosition),把旧的滚动位置和状态都继承过来。
  • 这样一来,用户不会看到“跳回到顶部”的情况,滚动是连续的。

setPixels(非常重要)

  /// 将滚动位置([pixels])更新为指定的像素值。
  ///
  /// 只能由当前的 [ScrollActivity] 调用,
  /// 要么是在临时回调阶段,要么是响应用户输入时调用。
  ///
  /// 返回值表示是否有越界(overscroll)。
  /// - 如果返回 0.0,说明 [pixels] 已成功更新为给定的 `value`。
  /// - 如果返回值 > 0,说明新的 [pixels] 小于请求值(超出了最大滚动范围)。  
  /// - 如果返回值 < 0,说明新的 [pixels] 大于请求值(超出了最小滚动范围)。  
  ///
  /// 越界量由 [applyBoundaryConditions] 计算。
  ///
  /// 实际应用的偏移变化量通过 [didUpdateScrollPositionBy] 上报。  
  /// 如果有越界,则通过 [didOverscrollBy] 上报。


double setPixels(double newPixels) {
  assert(hasPixels); // 必须已有有效的像素位置

  // 不能在 build/layout/paint 阶段修改滚动位置,否则会干扰渲染流程
  assert(
    SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks,
    "不能在 build/layout/paint 阶段修改滚动位置!"
  );

  // 如果新的像素和当前不同
  if (newPixels != pixels) {
    // 计算边界条件(检查是否超出 min/max)
    final double overscroll = applyBoundaryConditions(newPixels);

    // debug 校验:overscroll 不能比实际移动量还大
    assert(() {
      final double delta = newPixels - pixels;
      if (overscroll.abs() > delta.abs()) {
        throw FlutterError("applyBoundaryConditions 返回了非法的 overscroll 值!");
      }
      return true;
    }());

    // 保存旧值,并更新为「newPixels - overscroll」
    final double oldPixels = pixels;
    _pixels = newPixels - overscroll;

    // 如果确实有变化
    if (_pixels != oldPixels) {
      if (outOfRange) {
        context.setIgnorePointer(false); // 越界时允许子组件接收触摸事件
      }
      notifyListeners(); // 通知监听器滚动位置改变
      didUpdateScrollPositionBy(pixels - oldPixels); // 报告实际偏移变化量
    }

    // 如果存在越界(大于精度容忍范围)
    if (overscroll.abs() > precisionErrorTolerance) {
      didOverscrollBy(overscroll); // 报告越界
      return overscroll; // 返回越界值
    }
  }

  return 0.0; // 没有变化 / 没有越界
}

setPixels 就是 “把滚动条拉到某个位置” 的底层实现,流程是:

  1. 检查新位置 newPixels
  2. 看看会不会超出范围(用 applyBoundaryConditions 算出 overscroll)。
  3. 如果能更新,就更新 _pixels 并通知监听器。
  4. 如果越界了,调用 didOverscrollBy 上报越界情况。

📌 举个例子

  • 列表最大滚动范围 = 1000,当前滚动到 900

  • 调用 setPixels(1200)

    • 目标是 1200,但超过最大范围 1000,overscroll = 200
    • 实际 _pixels 设置为 1000,返回 200,并触发 didOverscrollBy(200)

correctPixels

/// 将 [pixels] 的值直接改为新的值,但不会通知任何监听者。
///
/// 这个方法主要用于 **布局过程 (layout)** 中调整位置。
/// 特别是在 [applyViewportDimension] 或 [applyContentDimensions] 的响应中调用。
/// (在这两种情况下,如果调用了该方法,那么它们通常应该返回 `false`,
/// 以表明位置已经被调整过了。)
///
/// 在其他情况下调用这个方法几乎都是不正确的。
/// 因为它不会立即触发渲染变化 ——
/// 依赖这个对象的 widget 或 render object 不会立刻被通知,
/// 它们只有在下一次读取该值时才会发现变化(可能会很晚才发生)。
///
/// 因此,通常只有在 **布局阶段修正值** 时才适用,
/// 尤其是 [ScrollPosition] 只有一个 viewport 客户端时。
///
/// 如果你想让位置立即跳转或动画滚动到一个新值,
/// 请考虑使用 [jumpTo] 或 [animateTo],
/// 它们会遵循正常的滚动偏移更新规则。
///
/// 如果你想绕过正常规则,强制把 [pixels] 设为某个值,
/// 可以使用 [forcePixels](不过请注意它的注释,
/// 即便如此,这通常仍然不是一个好主意)。
///
/// 另见:
///
///  * [correctBy] —— [ViewportOffset] 的一个方法,
///    用于在布局过程中修正 offset,但不会通知监听者。
///  * [jumpTo] —— 在不处于布局阶段时,
///    立即修改位置并应用新的偏移。
///  * [animateTo] —— 类似 [jumpTo],但会通过动画滚动到目标偏移。

void correctPixels(double value) {
  _pixels = value;
}

非常简单:就是直接改 _pixels,完全 不触发任何通知或回调

🌟 使用场景

  • 什么时候用?

    • 布局过程中,因为内容尺寸或视口尺寸变化,需要“强制修正”滚动位置。
    • 比如:屏幕旋转、父布局大小变化、列表内容突然减少等。
  • 为什么不能乱用?

    • 因为它不会调用 notifyListeners(),所以界面不会立刻更新。
    • 如果不是在布局阶段用,UI 可能“卡住”,直到下一次系统主动读取 pixels

npm发包自己的组件并安装更新版本应该如何做?

作者 林太白
2025年8月16日 10:42

npm发包组件

发布一个属于自己的npm包吧!接下来我们便使用Vue封装组件并发布到npm仓库

封装NPM组件-验证码

预览

npm-fabao1.png

1、创建账号注册登录

👉注册申请以及登录账号

官网

https://www.npmjs.com/

正常申请注册即可,选择 sign up 进入账户注册页面

npm-fabao2.png

2、创建vue3项目Tbcode

👉搭建项目

yarn create vite NexusCode --template vue

// 安装依赖
yarn 

👉创建组件

<template>
  <div class="necode">
    <div 
      class="codebox"
      :style="{
        'background': codeback,
        'width': width + 'px',
        'height': height + 'px'
      }"
      @click="getCode(length)"
    >
      {{ codevalue }}
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';

// 接收父组件传递的 props
defineProps({
  value: {
    type: String,
    required: false,
  },
  length: {
    type: Number,
    default: 4,
    required: false,
  },
  back: {
    type: String,
    required: false,
  },
  width: {
    type: Number,
    default: 120,  // 默认宽度为120px
  },
  height: {
    type: Number,
    default: 40,   // 默认高度为40px
  }
});


const codelength = ref(4);

// 响应式变量
const codevalue = ref(''); // 验证码值
const codeback = ref('');  // 验证码背景色

// onMounted 是 Vue 3 的生命周期钩子,类似于 Vue 2 的 created
onMounted(() => {
  codelength.value=length?length:codelength.value;
  getCode(codelength.value); // 获取验证码
});

// 新增试用码-前端随机生成方法
const getCode = (row) => {
  // 随机背景颜色
  const r = Math.floor(Math.random() * 256);
  const g = Math.floor(Math.random() * 256);
  const b = Math.floor(Math.random() * 256);
  const rgb = `rgb(${r},${g},${b})`;
  codeback.value = rgb;

  const arrall = [
    'A', 'B', 'C', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'W', 'X', 'Y', 'Z', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
  ];
  let str = '';
  for (let i = 0; i < row; i++) {
    str += arrall[Math.floor(Math.random() * arrall.length)];
  }
  codevalue.value = str;
};
</script>

<style scoped>
.codebox {
  text-align: center;
  font-weight: 800;
  line-height: 40px;
  display: inline-block;
  float: left;
  cursor: pointer;
  font-size: 24px;
  color: #fff;
  border-radius: 4px;
}
</style>

👉配置package.json

私人化配置

"private": true,
这就代表私人的包

公共包配置(这里我们使用这个)

{
  "name": "tbcode",
  "version": "0.0.2",
  "files": [
    "dist"
  ],
  "module": "./dist/tbcode.es.js",
  "main": "./dist/tbcode.umd.js",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/tbcode.es.ts",
      "require": "./dist/tbcode.umd.ts"
    },
    "./dist/style.css": {
      "import": "./dist/tbcode.css",
      "require": "./dist/tbcode.css"
    }
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.5.18"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^6.0.1",
    "vite": "^7.1.2"
  }
}

3、发布组件

👉版本名字

🍎名称规则

包名使用了@开头,一般使用@符号开头的包都是私有包,npm需要收费

加上--access public可直接发布组件,当成公有包,不需要支付费用

name:发布的包名,默认是上级文件夹名。不得与现在npm中的包名重复。包名不能有大写字母/空格/下滑线!

👉版本登录发布

Major version:大版本,代表破坏性变更。
Minor version:小版本,代表向后兼容的新功能。
Patch version:修订版本,代表 bug 修复和小改进。
🍎 版本发布
//查看当前用户是否登录
npm whoami 

//登陆
npm login 

// 或  npm addUser  

//部署
npm publish

👉发布名称冲突报错

下面就是名字被占用了

npm notice
npm notice package: Necode@0.0.2
npm notice Tarball Contents
npm notice 385B README.md
npm notice 169B dist/Necode.css
npm notice 2.0kB dist/necode.es.js
npm notice 1.3kB dist/necode.umd.js
npm notice 613B package.json
npm notice Tarball Details
npm notice name: Necode
npm notice version: 0.0.2
npm notice filename: Necode-0.0.2.tgz
npm notice package size: 2.1 kB
npm notice unpacked size: 4.4 kB
npm notice shasum: 6534df3352b5d299457dbff0b6e363b1b6ab6d4f
npm notice integrity: sha512-UZ1QGtymSFl73[...]rc1luwb77w/4Q==
npm notice total files: 5
npm notice
npm notice Publishing to 
  https://registry.npmjs.org/ with tag latest and default access
npm error code E400
npm error 400 Bad Request - PUT https://registry.npmjs.org/Necode - 
"Necode" is invalid for new packages
npm error A complete log of this run can be found in: 
C:\Users\admin\AppData\Local\npm-cache\_logs\2025-08-15T08_24_42_644Z-debug-0.log

👉更新名字,再次发布

npm publish提示信息如下

npm notice
npm notice package: tbcode@0.0.2
npm notice Tarball Contents
npm notice 385B README.md
npm notice 169B dist/Necode.css
npm notice 2.0kB dist/necode.es.js
npm notice 1.3kB dist/necode.umd.js
npm notice 613B package.json
npm notice Tarball Details
npm notice name: tbcode
npm notice version: 0.0.2
npm notice filename: tbcode-0.0.2.tgz
npm notice package size: 2.1 kB
npm notice unpacked size: 4.4 kB
npm notice shasum: 97d3dc40035c0e3fcbcb590704e7c0d531d9d16e
npm notice integrity: sha512-M4EQ/J8XRHZNT[...]CC0OwXVluzH8Q==
npm notice total files: 5
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and default access
+ tbcode@0.0.2

搜索已经可以发现我们的npm包了

tbcode

👉更新版本

接下来我们传第二个版本包

添加一些关键词

// 发布一个小的补丁号版本
npm version patch
npm publish

这个时候已经可以看到我们的关键词和版本信息了

Keywords
vuereacttbcodelintaibai林太白
npm version patch -m "xx"
【
    patch增加一位补丁号,
    minor增加一位小版本号,
    major增加一位大版本号
 】

👉取消版本

// 舍弃某个版本的模块  24小时使用这个即可(使用)
npm unpublish tbcode@1.0.0

// 舍弃某个版本的模块
npm deprecate my-npm@"< 1.0.8" "critical bug fixed in v1.0.2"

// 如何撤销发布
npm --force unpublish my_npm

4、使用

发布到 npm 后,安装使用自己封装的组件

安装依赖

npm i tbcode

//或者
yarn add tbcode

项目引入使用

import tbCode from 'tbCode'
import 'tbcode/dist/style.css'

app.component('Tbcode', tbcode) 


<Tbcode/>

问题以及处理

👉组件样式不生效,

问题就出现在下面两点

(1)组件的导出方式配置不对

(2)使用时候引入没有

// 导出方式配置
"exports": {
    ".": {
      "import": "./dist/tbcode.es.js",
      "require": "./dist/tbcode.umd.js"
    },
    "./dist/style.css": {
      "import": "./dist/tbcode.css",
      "require": "./dist/tbcode.css"
    }
},


// 引入使用
import 'tbcode/dist/style.css'

告别jQuery:2025年原生DOM操作最佳实践

作者 艾小码
2025年8月16日 07:49

随着现代浏览器对ECMAScript标准的全面支持,原生JavaScript已能高效替代jQuery的绝大多数功能。本文将深入探讨2025年DOM操作的核心优化策略,助你构建高性能前端应用。

一、选择器性能优化:querySelector陷阱与getElementById的抉择

querySelector的隐藏代价
虽然querySelectorquerySelectorAll提供了类似jQuery的CSS选择器语法,但其性能表现与选择器复杂度直接相关:

// 简单ID选择器
const element = document.querySelector('#myId'); 

// 复杂嵌套选择器
const nestedItems = document.querySelectorAll('div.container > ul.list > li:first-child');

当解析复杂选择器时,浏览器需遍历DOM树进行模式匹配,消耗时间与DOM规模成正比。尤其在万级节点中频繁调用时,可能成为性能瓶颈。

getElementById的极致优化
专为ID查找设计的API具备显著优势:

// 直接通过哈希映射定位元素
const element = document.getElementById('myId');

浏览器内部维护全局ID索引,使得时间复杂度稳定为O(1)。测试表明,其执行速度比querySelector('#id')快约15-30%。

决策矩阵:何时选用何种API

场景 推荐API 性能依据
单元素ID查找 getElementById 直接访问哈希索引,零解析开销
简单类选择(单个元素) querySelector 仅需解析单类选择器
复杂组合选择 querySelectorAll 牺牲部分性能换取开发效率
动态元素集合 getElementsByClassName 返回实时HTMLCollection,响应DOM变化

实践建议:在循环或动画中优先使用getElementByIdgetElementsByClassName;复杂静态元素组可缓存querySelectorAll结果避免重复查询。

二、高效批量操作:DocumentFragmentwill-change的协同

DocumentFragment:离线DOM的原子化操作
作为轻量级虚拟容器,其核心优势在于:

const fragment = document.createDocumentFragment();

// 批量创建节点(不触发重排)
for(let i=0; i<1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  fragment.appendChild(li);
}

// 单次插入(仅1次重排)
document.getElementById('list').appendChild(fragment);

通过脱离文档流的特性,使中间操作完全避开渲染管线,将N次重排压缩为1次。实测显示,万级节点插入耗时从12s降至350ms。

will-change:GPU加速的预优化
当需要对现有元素进行连续动画时,CSS提示可触发硬件加速:

.animated-element {
  will-change: transform, opacity; 
  transition: transform 0.3s ease-out;
}

此声明通知浏览器预先将元素提升至独立合成层,避免后续transform/opacity变化引发重排。但需注意过度使用会导致内存暴涨。

双剑合璧技术方案

  1. 静态节点批量插入
    DocumentFragment创建 → 填充内容 → 单次挂载DOM
    (适用:列表初始化、大块模板渲染)

  2. 动态元素连续动画
    添加will-change提示 → 使用transform/opacity驱动动画 → 动画结束移除提示
    (适用:拖拽、滚动特效、渐变过渡)

关键警示will-change应作为最终优化手段,而非预防性添加。过度使用将导致层爆炸(Layer Explosion),移动设备内存开销可超300MB。

三、虚拟滚动:万级列表渲染的核心实现

虚拟滚动通过动态可视区域渲染破解性能困局,核心流程:

1. 布局引擎(Layout Engine)

const container = {
  clientHeight: 800,   // 可视区域高度
  itemHeight: 50,      // 单项预估高度
  bufferSize: 5,       // 渲染缓冲项数
};

// 计算可见项索引
const startIdx = Math.floor(scrollTop / itemHeight);
const endIdx = startIdx + Math.ceil(clientHeight / itemHeight) + bufferSize;

2. 动态渲染(Dynamic Rendering)

<div class="viewport" style="height:800px; overflow-y: auto">
  <!-- 撑开总高度的占位符 -->
  <div class="scroll-holder" style="height:${totalItems * itemHeight}px"></div> 
  
  <!-- 仅渲染可视项 -->
  <div class="visible-items" style="position:relative; top:${startIdx * itemHeight}px">
    ${visibleItems.map(item => `<div class="item">${item.text}</div>`)}
  </div>
</div>

3. 滚动优化(Scroll Optimization)

  • 使用requestAnimationFrame节流滚动事件
  • 持久化已渲染节点避免重复创建
  • 异步加载非可视区数据

性能对比(渲染10,000项)

方案 初始化时间 滚动帧率 内存占用
传统全量渲染 4200ms 8fps 850MB
虚拟滚动 380ms 60fps 95MB

进阶技巧:结合IntersectionObserver实现懒加载,使用ResizeObserver处理动态项高,并采用渐进渲染策略避免跳帧。

四、架构启示:现代DOM操作核心原则

  1. 选择性优化
    仅对滚动容器动画高频区等关键路径实施虚拟化,避免过度工程化

  2. 读写分离
    集中执行样式修改后统一读取布局属性,防止强制同步布局(Forced Synchronous Layout)

    // 错误示范(读写交错)
    elements.forEach(el => {
      el.style.width = (el.offsetWidth + 10) + 'px'; // 触发重排
    });
    
    // 正确做法(批量写 → 批量读)
    elements.forEach(el => el.style.width = '110%');
    const newWidths = elements.map(el => el.offsetWidth);
    
  3. 分层渲染策略

    // 首次加载 → 数据量>500 → 虚拟滚动+骨架屏 → 滚动时增量加载
    // 首次加载 → 数据量<500 → 全量渲染 → 常规交互
    

2025年最佳实践组合

  • 选择器getElementById + 缓存querySelectorAll
  • 批量操作DocumentFragment + CSS contain:content
  • 长列表:虚拟滚动 + IntersectionObserver
  • 动画will-change + transform硬件加速

原生DOM操作并非简单替换jQuery语法,而是重新理解浏览器渲染管线。通过精准选择API、利用硬件加速、动态加载策略,即使处理十万级DOM也能保持60fps流畅体验。在前端框架盛行的今天,掌握原生能力仍是性能优化的终极底牌。

还在用for循环遍历DOM?试试更优雅的NodeIterator与TreeWalker吧

作者 coding随想
2025年8月16日 09:28

在前端开发中,DOM操作是绕不开的核心技能。无论是动态渲染页面、处理用户交互,还是实现复杂的动画效果,DOM的遍历与操作始终是关键环节。然而,传统的for循环或递归遍历DOM的方式往往显得笨拙且低效。今天,我们将揭开DOM2 Traversal and Range模块的神秘面纱,深入解析其定义的两个核心工具:NodeIteratorTreeWalker。它们不仅能让你像“走迷宫”一样高效遍历DOM树,还能通过灵活的过滤机制精准定位目标节点。


一、DOM2 Traversal模块的前世今生

DOM2 Traversal and Range模块是W3C在2000年提出的标准之一,旨在为开发者提供更高效、更灵活的DOM操作方式。其中,NodeIteratorTreeWalker是模块的核心工具,它们通过深度优先遍历(Depth-First Traversal)的方式,从指定的根节点出发,系统化地访问DOM树中的每一个节点。

1.1 深度优先遍历的奥秘

深度优先遍历是一种“先深入子树,再回溯”的遍历策略。例如,对于以下HTML结构:

<div id="root">
  <p>段落1</p>
  <ul>
    <li>项目1</li>
    <li>项目2</li>
  </ul>
</div>

<div>为根节点的遍历顺序将是:

  1. <div>
  2. <p>
  3. 文本节点“段落1”
  4. <ul>
  5. <li>(项目1)
  6. 文本节点“项目1”
  7. <li>(项目2)
  8. 文本节点“项目2”

这种遍历方式与人类阅读文档的逻辑高度一致,尤其适合处理嵌套层级复杂的DOM结构。


二、NodeIterator与TreeWalker的对比

DOM2 Traversal模块提供了两种遍历工具,它们各有特点,适用于不同的场景。

2.1 NodeIterator:简单粗暴的“单向通道”

NodeIterator是功能较为简单的遍历器,它通过document.createNodeIterator()方法创建,仅支持单向遍历(从根节点到叶子节点)。

核心参数

  • root:遍历的起点节点。
  • whatToShow:通过位掩码(Bitmask)指定需要访问的节点类型(如元素、文本、注释等)。
  • filter:过滤函数或NodeFilter对象,用于决定是否接受某个节点。
  • entityReferenceExpansion:是否扩展实体引用(HTML中无效,默认设为false)。

常用方法

  • nextNode():返回下一个节点。
  • previousNode():仅在TreeWalker中可用,NodeIterator不支持反向遍历。

代码示例

// 创建一个NodeIterator,仅遍历元素节点
const iterator = document.createNodeIterator(
  document.getElementById("root"),
  NodeFilter.SHOW_ELEMENT,
  null,
  false
);

let node;
while ((node = iterator.nextNode())) {
  console.log(node); // 输出所有元素节点
}

应用场景

  • 快速遍历特定类型的节点(如仅需处理元素节点)。
  • 配合过滤函数实现精准筛选(如提取所有<p>标签)。

注意事项

  • 单向性限制:无法回溯到父节点或兄弟节点。
  • 性能优势:由于逻辑简单,执行效率较高。

2.2 TreeWalker:灵活的“双向探索者”

TreeWalker是NodeIterator的“加强版”,它不仅支持深度优先遍历,还允许双向移动(向上、向下、左右跳转),适合需要动态调整遍历路径的场景。

核心方法

  • parentNode():跳转到当前节点的父节点。
  • firstChild():跳转到第一个子节点。
  • lastChild():跳转到最后一个子节点。
  • nextSibling():跳转到下一个兄弟节点。
  • previousSibling():跳转到上一个兄弟节点。

代码示例

// 创建一个TreeWalker,遍历所有节点
const walker = document.createTreeWalker(
  document.getElementById("root"),
  NodeFilter.SHOW_ALL,
  null,
  false
);

// 向下遍历到第一个子节点
walker.firstChild();
console.log(walker.currentNode); // 输出第一个子节点

// 向上回溯到父节点
walker.parentNode();
console.log(walker.currentNode); // 输出根节点

应用场景

  • 动态调整遍历路径(如导航菜单的展开/折叠)。
  • 复杂DOM结构的深度挖掘(如解析文档大纲)。

注意事项

  • 灵活性代价:双向操作可能增加代码复杂度。
  • 兼容性问题:IE浏览器不支持该模块(需通过polyfill或替代方案解决)。

三、实战技巧:让遍历事半功倍

3.1 过滤器的艺术

通过filter参数,你可以自定义节点的筛选规则。例如,仅遍历包含特定类名的元素:

const filter = {
  acceptNode: (node) => {
    return node.classList?.contains("highlight") 
      ? NodeFilter.FILTER_ACCEPT 
      : NodeFilter.FILTER_SKIP;
  }
};

const walker = document.createTreeWalker(
  document.body,
  NodeFilter.SHOW_ELEMENT,
  filter,
  false
);

3.2 位掩码的妙用

whatToShow参数支持按位或(|)组合多个节点类型。例如,同时遍历元素节点和文本节点:

const whatToShow = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT;

3.3 性能优化

  • 避免全量遍历:通过过滤器提前剪枝,减少不必要的节点访问。
  • 缓存遍历结果:对静态DOM结构,可将遍历结果存储为数组,避免重复操作。

四、常见误区与解决方案

4.1 遍历范围被意外限制

如果根节点设置不当,遍历可能无法覆盖预期区域。例如,以<body>为根节点时,无法访问<head>中的节点。

解决方案:将根节点设为document,确保覆盖整个DOM树。

4.2 动态DOM的陷阱

如果遍历过程中DOM结构被修改(如添加或删除节点),遍历器的行为可能变得不可预测。

解决方案

  • 在遍历前冻结DOM结构。
  • 使用MutationObserver监听变化并重新初始化遍历器。

4.3 兼容性地狱

IE浏览器不支持DOM2 Traversal模块,可能导致代码失效。

解决方案

  • 使用document.implementation.hasFeature("Traversal", "2.0")检测兼容性。
  • 通过递归或第三方库(如jQuery)实现兼容性方案。

五、总结:选择合适的武器

特性 NodeIterator TreeWalker
遍历方向 单向(仅向下) 双向(上下左右)
方法丰富度 简单(仅nextNode() 丰富(支持跳转操作)
性能 略低(因灵活性)
适用场景 快速筛选特定节点 动态调整遍历路径

NodeIterator适合“一次性任务”,而TreeWalker更适合“探索式任务”。掌握它们,你将能像棋手一样精准操控DOM,用代码书写优雅的前端篇章。

Next.js 嵌套路由与中间件:数据与逻辑的前哨站

作者 LeonGao
2025年8月16日 09:28

在现代 Web 应用的世界里,路由是城市道路,中间件是守在路口的警察,确保一切交通有序、安全。
Next.js 则是那位既懂交通规则、又能修路铺桥的工程师——你不仅可以在它的路网上自由嵌套路线,还可以让中间件在用户抵达目的地前对他们的身份、行李、甚至心情(如果你愿意)做检查。


一、嵌套路由的本质

在 Next.js 中,文件即路由的哲学让你少了很多配置文件的负担,但当你需要结构化复杂页面时,嵌套路由就派上了用场。

比如,你有一个博客系统:

/app
  /blog
    /page.js
    /[slug]
      /page.js
  • /blog → 博客列表页
  • /blog/[slug] → 某篇博客详情页

底层原理:

  • Next.js 会遍历 app 目录下的文件夹结构。
  • 目录名映射为 URL 路径,[param] 形式表示动态路由。
  • 嵌套文件夹会形成嵌套路由,父级路由可以包含 Layout,用来统一头部、底部、导航栏。

Layout 嵌套机制

// app/blog/layout.js
export default function BlogLayout({ children }) {
  return (
    <div>
      <header>Blog Header</header>
      <main>{children}</main>
    </div>
  );
}

这样 /blog/blog/[slug] 都会共享这个 BlogLayout,底层是组件树递归渲染,Next.js 会为每一层 Layout 建立独立 React 节点,从而实现父子关系。


二、中间件(Middleware)的使命

想象一下你有一个高档餐厅(网站),中间件就是门口的保安——

  • 检查身份证(鉴权)
  • 检查预订记录(权限控制)
  • 检查是否穿正装(条件跳转)
  • 甚至可以把迟到的人送去别的餐厅(重定向)

中间件的运行时机

  • 请求到达页面组件之前
  • 运行在 Edge Runtime(轻量、低延迟,全球分布)。
  • 可以读取和修改请求、响应。

底层机制

  • 你在项目根目录(或子目录)下放置一个 middleware.js 文件。
  • Next.js 会在构建时将它编译为 Edge Function。
  • 每次请求进入匹配的路径时,都会先经过中间件逻辑。

三、实战:嵌套路由 + 中间件

假设你有一个 /dashboard 路由和它的嵌套页面 /dashboard/settings,你想在用户进入这些页面前检查是否已登录。

目录结构:

/app
  /dashboard
    /page.js
    /settings
      /page.js
/middleware.js

中间件示例:

// middleware.js
import { NextResponse } from 'next/server';

export function middleware(req) {
  const token = req.cookies.get('token');
  
  if (!token) {
    // 未登录则跳转到登录页
    return NextResponse.redirect(new URL('/login', req.url));
  }
  
  // 已登录则放行
  return NextResponse.next();
}

// 限制中间件只匹配 dashboard 路由
export const config = {
  matcher: ['/dashboard/:path*']
};

四、嵌套路由与中间件的协作

嵌套路由提供结构化的页面层级,而中间件提供请求入口的守卫
就像机场一样:

  • 嵌套路由 → 航站楼结构(国际、国内、贵宾厅等分区)
  • 中间件 → 安检口(拦截违禁品、核对身份、放行)

好处:

  1. 安全:中间件阻挡未授权用户。
  2. 体验:减少无意义的页面渲染。
  3. 性能:Edge Runtime 在边缘节点直接处理,不必每次回到主服务器。

五、最佳实践建议

  1. 中间件逻辑要精简

    • 它运行在边缘节点,不适合做大量计算。
    • 适合做快速判断、重定向、设置 cookie。
  2. 嵌套路由中 Layout 复用 UI

    • 避免重复代码,让不同子页面共享样式和结构。
  3. 分层控制

    • 根目录 middleware.js 管全局规则。
    • 子目录 middleware.js 处理局部规则(Next.js 13+ 支持子目录中间件)。

六、幽默的尾声

嵌套路由像一座大厦的楼层结构,
中间件是大门口的保安,
而 Next.js 是那位能帮你造大厦、请保安、装电梯的承包商。

有人会问:
“那如果我没中间件,直接让所有人进来会怎样?”
——那就像把你家 Wi-Fi 密码贴在电梯里,很快就会发现隔壁邻居比你还熟悉你的路由结构

AI UI 数据展示:Chart.js / Recharts + AI 总结文本的艺术

作者 LeonGao
2025年8月16日 09:25

在现代 Web 应用的世界里,数据展示早已不再是枯燥的表格,而是一场视觉盛宴。
就像数据是食材,AI 是大厨,Chart.js / Recharts 是精致的餐具——最终的 UI 是那道端上用户桌面的米其林级菜肴

本篇文章,我们将从底层原理到代码实践,一起探讨如何用 Chart.js / Recharts 绘制出优雅的数据图表,并用 AI 自动生成人类可读的总结文本


一、为什么 Chart.js 和 Recharts 是好搭档?

在前端图表界,Chart.js 和 Recharts 有点像两个性格不同的朋友:

  • Chart.js

    • 优势:轻量级,原生 Canvas 渲染,动画丝滑。
    • 适合场景:需要快速渲染高性能、交互不太复杂的图表。
    • 底层机制:直接操作 <canvas>,用 2D 渲染上下文绘制像素。
    • 缺点:配置复杂时需要更多手动调整。
  • Recharts

    • 优势:基于 React 组件化开发,易维护,语义化强。
    • 适合场景:React 项目里快速搭建交互性强的图表。
    • 底层机制:基于 D3.js 的计算和 SVG 渲染(矢量图,缩放不失真)。
    • 缺点:在大量数据点时性能可能逊色于 Canvas。

一句话总结

Chart.js 是“性能小钢炮”,Recharts 是“优雅绅士”,你可以根据业务场景选择或混用。


二、AI 在数据展示中的角色

如果 Chart.js 和 Recharts 是负责画画的,那 AI 就是旁白解说员

为什么需要 AI 文本总结?

  • 人眼对趋势敏感,但 AI 可以直接用自然语言告诉你结论
  • 当用户面对一堆数据曲线时,AI 可以说:“看!这个月的销售额比上月增长了 35%,并且主要得益于东南亚市场的爆发式增长。”

AI 的底层工作逻辑:

  1. 获取数据(JSON / API)。
  2. 特征提取:计算平均值、最大值、趋势变化率等。
  3. 语言生成:将这些特征喂给 AI 模型(如 GPT-4、Claude),让它用自然语言总结。
  4. 输出优化:控制字数、调整语气、加上商业或技术背景。

三、数据流的底层原理

一个典型的 AI UI 数据展示系统,数据流是这样的:

[ 数据源 API ][ 前端获取数据 fetch() ][ 数据处理:统计、归一化 ][ Chart.js / Recharts 渲染 ][ AI 调用接口生成总结文本 ][ 页面展示:图表 + 文本 ]

在底层实现里,Chart.js 会直接操作 Canvas 的像素点,而 Recharts 会在 DOM 中生成 <svg> 标签,并通过 D3.js 计算坐标和路径。

AI 部分则通常通过 HTTP 请求调用 LLM API,比如:

const summary = await fetch('/api/ai-summary', {
  method: 'POST',
  body: JSON.stringify({ data }),
});

在服务器上,你可能用 OpenAI API:

import OpenAI from 'openai';
const openai = new OpenAI();

const aiText = await openai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [
    { role: "system", content: "你是数据分析师,帮我总结趋势" },
    { role: "user", content: JSON.stringify(data) }
  ]
});

四、实战示例:Chart.js + AI 总结

假设我们有一组销售额数据(按月份),我们先用 Chart.js 画出来,再调用 AI 给出文字总结。

import { Chart } from 'chart.js';

// 模拟数据
const salesData = [120, 140, 180, 160, 200, 250, 300];
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'];

// 1. 绘制图表
new Chart(document.getElementById('salesChart'), {
  type: 'line',
  data: {
    labels,
    datasets: [{
      label: 'Monthly Sales',
      data: salesData,
      borderColor: '#4CAF50',
      fill: false
    }]
  }
});

// 2. 请求 AI 总结
async function getAISummary(data) {
  const res = await fetch('/api/ai-summary', {
    method: 'POST',
    body: JSON.stringify({ salesData: data })
  });
  const { summary } = await res.json();
  document.getElementById('summary').innerText = summary;
}

getAISummary(salesData);

五、Recharts + AI 总结(React 版本)

import { LineChart, Line, XAxis, YAxis, Tooltip } from 'recharts';

const data = [
  { month: 'Jan', sales: 120 },
  { month: 'Feb', sales: 140 },
  { month: 'Mar', sales: 180 },
  { month: 'Apr', sales: 160 },
  { month: 'May', sales: 200 },
  { month: 'Jun', sales: 250 },
  { month: 'Jul', sales: 300 }
];

export default function SalesChart() {
  return (
    <>
      <LineChart width={500} height={300} data={data}>
        <XAxis dataKey="month" />
        <YAxis />
        <Tooltip />
        <Line type="monotone" dataKey="sales" stroke="#4CAF50" />
      </LineChart>
      <div id="summary">AI 正在生成总结...</div>
    </>
  );
}

在 React 中,可以用 useEffect 触发 AI 总结的 API 调用,将数据传过去,再更新到 summary 状态中。


六、幽默的收尾

传统的数据展示是“看图说话”,
AI + 图表的组合是“看图不用说话,AI 替你说完”。

当 Chart.js 像年轻的涂鸦艺术家用画笔在 Canvas 上狂飙,
Recharts 则是那位戴着圆框眼镜、温文尔雅的 SVG 绘图师。
而 AI,就像后台的那位戏精,随时准备为你的数据配上旁白——
甚至会夸张地说你是下一个商业传奇。

Vue SSR原理

2025年8月16日 08:44

当搜索引擎的爬虫访问我们的站点时,如果只看到一句冷冰冰的 <div id="app"></div>,SEO 基本就凉了。Vue SSR(Server-Side Rendering,服务端渲染)正是为了解决这个问题:让首屏 HTML 在服务器上生成,既能被爬虫读懂,又能让用户以最短的时间看到内容。

一、两份入口文件,一个共享的“根”

Vue 项目的传统 SPA 只有一个 main.js,而 SSR 需要两个入口:

  • app.js

    这是“纯粹”的 Vue 根实例工厂,既不挂载 DOM,也不关心运行在哪个环境。它返回一个干净的 new Vue(),被客户端和服务端共同引用。

  • client-entry.js

    拿到 app.js 返回的实例后,直接 mount('#app'),把静态标记激活成可交互的 SPA。

  • server-entry.js

    在 Node 环境里执行,职责有三件:

    1. 调用 app.js 创建根实例;
    2. 根据请求 URL 做路由匹配,找到需要渲染的组件;
    3. 执行组件暴露的 asyncDatafetch,把数据预取到 Vuex store。

这样设计让“渲染”与“激活”解耦,同一份业务代码跑在两端。

二、Webpack 打出两份 Bundle

构建时,Webpack 会跑两次:

  • Client Bundle

    打包所有客户端代码,输出到 dist/client,浏览器下载后负责激活静态 HTML。

  • Server Bundle

    打包所有服务端代码,输出到 dist/server,Node 进程通过 vue-server-renderer 读取这份 Bundle,生成首屏 HTML。

两份 Bundle 都包含业务组件,但前者带浏览器运行时,后者只保留渲染逻辑,体积更小。

三、服务器收到请求,一条流水线干活

当用户或爬虫发来 GET /article/42

  1. Node 进程加载 server-entry.js,创建一个新的 Vue 实例。
  2. 路由匹配到 Article.vue,触发 asyncData 钩子,拉取文章详情并写入 store。
  3. vue-server-renderer 把组件树渲染成字符串,插入到模板中的 <!--vue-ssr-outlet--> 占位符里。
  4. 为了让客户端“无缝续命”,服务器把 store 状态序列化成一段脚本:
   <script>window.__INITIAL_STATE__ = {...}</script>
  1. 最终拼好的 HTML 响应给浏览器,首屏直出完成。

四、浏览器“激活”静态标记

浏览器拿到 HTML 后,做了三件事:

  1. 解析 DOM 并立即渲染,用户瞬间看到文章标题与正文。

  2. 加载 Client Bundle,执行 client-entry.js,创建同构的 Vue 实例。

  3. 通过 __INITIAL_STATE__ 恢复 store 数据,再调用 hydrate 而非 mount

    hydrate 会对比服务端返回的 DOM 与客户端虚拟 DOM,复用已有节点、绑定事件,把“死”的 HTML 激活成“活”的 SPA。

这一步叫 客户端激活(Client Hydration),只有 DOM 结构与数据完全一致才能成功,否则 Vue 会整段替换,带来性能损耗。

五、交互回归 SPA 常态

激活完成后,所有路由跳转、数据更新都由浏览器接管,退化为普通单页应用。

SSR 只在首屏出场一次,之后不再参与。这样既享受了 SEO 与首屏性能,又保留了 SPA 的流畅体验。

六、总结

VUE SSR 的核心思想是让服务器先跑一次组件的渲染函数,把结果 HTML 交给浏览器,浏览器再用同一份代码激活它。

JavaScript 代理(Proxy)与反射(Reflect)详解

作者 excel
2025年8月16日 08:31

在现代 JavaScript 开发中,代理(Proxy)反射(Reflect) 是两个非常强大的特性。
它们可以帮助我们 拦截对象操作、控制属性访问、实现验证逻辑,甚至实现响应式系统
下面我们按照知识点逐一展开。


1. 创建空代理

最基本的代理写法如下:

const target = { name: "Alice" };
const proxy = new Proxy(target, {}); // 空代理,没有拦截行为

proxy.age = 20;

console.log(proxy.name);  // "Alice"
console.log(target.age);  // 20

👉 空代理只是 target 的一个“镜像”,没有任何特殊功能。


2. 定义捕获器(Traps)

捕获器就是代理的“拦截器”。例如 get 捕获属性访问:

const target = { name: "Alice" };

const proxy = new Proxy(target, {
  get(obj, prop) {
    console.log(`访问属性: ${prop}`);
    return obj[prop];
  }
});

console.log(proxy.name); // 输出: 访问属性: name \n "Alice"

3. 捕获器与参数

大多数捕获器会接收 target、prop、receiver 参数。例如:

const target = { age: 25 };

const proxy = new Proxy(target, {
  get(t, prop, receiver) {
    console.log(`读取属性: ${prop}`);
    return Reflect.get(t, prop, receiver); // 推荐用 Reflect 保持一致性
  }
});

console.log(proxy.age); // 输出: 读取属性: age \n 25

4. 捕获器不变式

代理必须遵循 JavaScript 对象的不变式(invariants),否则会抛错。

// 示例一
const obj = Object.freeze({ x: 10 });

const proxy = new Proxy(obj, {
  get() {
    return 42; // ❌ 不能篡改冻结对象的不变式
  }
});

console.log(proxy.x); // 10,而不是 42


// 示例二
const obj = {};
Object.defineProperty(obj, "y", {
  value: 100,
  writable: false,      // ❌ 不可写
  configurable: false   // ❌ 不可重新定义
});

const proxy = new Proxy(obj, {
  get() {
    return 200; // ❌ 尝试违反不变式
  }
});

console.log(proxy.y); 
// 输出: 100
// 说明:代理必须保持与目标对象一致,
// 不允许修改不可写 + 不可配置属性的值

5. 可撤销代理

代理可以通过 Proxy.revocable 创建,并支持 撤销

const { proxy, revoke } = Proxy.revocable({ msg: "Hello" }, {
  get(t, prop) {
    return t[prop];
  }
});

console.log(proxy.msg); // "Hello"
revoke(); // 撤销代理
// console.log(proxy.msg); // ❌ 报错:代理已撤销

6. 实用反射 API

Reflect 提供了一套方法,和代理捕获器一一对应。

const obj = { x: 10 };

console.log(Reflect.get(obj, "x")); // 10
Reflect.set(obj, "y", 20);
console.log(obj.y); // 20

👉 使用 Reflect 操作对象,比直接操作更安全规范。


7. 代理另一个代理

代理本身也能被再次代理:

const target = { value: 1 };

const proxy1 = new Proxy(target, {
  get(t, prop) {
    console.log("proxy1 get");
    return Reflect.get(t, prop);
  }
});

const proxy2 = new Proxy(proxy1, {
  get(t, prop) {
    console.log("proxy2 get");
    return Reflect.get(t, prop);
  }
});

console.log(proxy2.value);
// 输出:proxy2 get \n proxy1 get \n 1

8. 代理的问题与不足

  • 性能开销:拦截操作有额外消耗。
  • 调试困难:过度使用会让逻辑难以理解。
  • 部分内建对象(如 DOM 节点)可能无法完全代理。

9. 代理捕获器与反射方法

每个捕获器都有对应的 Reflect 方法,推荐配合使用。下面给出示例:

9.1 get ↔ Reflect.get

const obj = { name: "Alice" };
const proxy = new Proxy(obj, {
  get(t, prop, receiver) {
    console.log(`读取 ${prop}`);
    return Reflect.get(t, prop, receiver);
  }
});
console.log(proxy.name); // 输出: 读取 name \n Alice

9.2 set ↔ Reflect.set

const obj = {};
const proxy = new Proxy(obj, {
  set(t, prop, value, receiver) {
    console.log(`设置 ${prop} = ${value}`);
    return Reflect.set(t, prop, value, receiver);
  }
});
proxy.age = 30; // 输出: 设置 age = 30

9.3 has ↔ Reflect.has

const obj = { x: 10 };
const proxy = new Proxy(obj, {
  has(t, prop) {
    console.log(`检查是否有 ${prop}`);
    return Reflect.has(t, prop);
  }
});
console.log("x" in proxy); // 输出: 检查是否有 x \n true

9.4 defineProperty ↔ Reflect.defineProperty

const obj = {};
const proxy = new Proxy(obj, {
  defineProperty(t, prop, desc) {
    console.log(`定义属性 ${prop}`);
    return Reflect.defineProperty(t, prop, desc);
  }
});
Object.defineProperty(proxy, "name", { value: "Bob" });
console.log(obj.name); // Bob

9.5 getOwnPropertyDescriptor ↔ Reflect.getOwnPropertyDescriptor

const obj = { a: 1 };
const proxy = new Proxy(obj, {
  getOwnPropertyDescriptor(t, prop) {
    console.log(`获取属性描述符 ${prop}`);
    return Reflect.getOwnPropertyDescriptor(t, prop);
  }
});
console.log(Object.getOwnPropertyDescriptor(proxy, "a"));

9.6 deleteProperty ↔ Reflect.deleteProperty

const obj = { secret: "123" };
const proxy = new Proxy(obj, {
  deleteProperty(t, prop) {
    console.log(`删除属性 ${prop}`);
    return Reflect.deleteProperty(t, prop);
  }
});
delete proxy.secret; // 输出: 删除属性 secret

9.7 ownKeys ↔ Reflect.ownKeys

const obj = { x: 1, y: 2 };
const proxy = new Proxy(obj, {
  ownKeys(t) {
    console.log("获取所有键");
    return Reflect.ownKeys(t);
  }
});
console.log(Object.keys(proxy)); // 输出: 获取所有键 \n ["x","y"]

9.8 getPrototypeOf ↔ Reflect.getPrototypeOf

const obj = {};
const proxy = new Proxy(obj, {
  getPrototypeOf(t) {
    console.log("获取原型");
    return Reflect.getPrototypeOf(t);
  }
});
console.log(Object.getPrototypeOf(proxy));

9.9 setPrototypeOf ↔ Reflect.setPrototypeOf

const obj = {};
const proto = { greet: () => "hi" };
const proxy = new Proxy(obj, {
  setPrototypeOf(t, proto) {
    console.log("设置原型");
    return Reflect.setPrototypeOf(t, proto);
  }
});
Object.setPrototypeOf(proxy, proto); // 输出: 设置原型
console.log(obj.greet()); // hi

9.10 isExtensible ↔ Reflect.isExtensible

const obj = {};
const proxy = new Proxy(obj, {
  isExtensible(t) {
    console.log("检查是否可扩展");
    return Reflect.isExtensible(t);
  }
});
console.log(Object.isExtensible(proxy)); // true

9.11 preventExtensions ↔ Reflect.preventExtensions

const obj = {};
const proxy = new Proxy(obj, {
  preventExtensions(t) {
    console.log("禁止扩展");
    return Reflect.preventExtensions(t);
  }
});
Object.preventExtensions(proxy); // 输出: 禁止扩展

9.12 apply ↔ Reflect.apply

function sum(a, b) { return a + b; }
const proxy = new Proxy(sum, {
  apply(fn, thisArg, args) {
    console.log("调用函数:", args);
    return Reflect.apply(fn, thisArg, args);
  }
});
console.log(proxy(2, 3)); // 输出: 调用函数: [2,3] \n 5

9.13 construct ↔ Reflect.construct

function Person(name) { this.name = name; }
const proxy = new Proxy(Person, {
  construct(t, args, newTarget) {
    console.log("构造函数调用:", args);
    return Reflect.construct(t, args, newTarget);
  }
});
const p = new proxy("Alice"); 
// 输出: 构造函数调用: [ 'Alice' ]
console.log(p.name); // Alice

10. 跟踪属性访问

function track(obj) {
  return new Proxy(obj, {
    get(t, prop) {
      console.log(`访问 ${prop}`);
      return Reflect.get(t, prop);
    }
  });
}
const user = track({ name: "Tom", age: 20 });
console.log(user.name);

11. 隐藏属性

function hide(obj, keys) {
  return new Proxy(obj, {
    get(t, prop) {
      if (keys.includes(prop)) return undefined;
      return Reflect.get(t, prop);
    },
    ownKeys(t) {
      return Reflect.ownKeys(t).filter(k => !keys.includes(k));
    }
  });
}
const user = hide({ name: "Alice", password: "123" }, ["password"]);
console.log(user.password); // undefined
console.log(Object.keys(user)); // ["name"]

12. 属性验证

const person = new Proxy({}, {
  set(t, prop, value) {
    if (prop === "age" && typeof value !== "number") {
      throw new TypeError("年龄必须是数字");
    }
    return Reflect.set(t, prop, value);
  }
});
person.age = 30;   // ✅
person.age = "20"; // ❌ 抛错

13. 函数与构造函数参数验证

function sum(a, b) { return a + b; }

const safeSum = new Proxy(sum, {
  apply(fn, thisArg, args) {
    if (!args.every(n => typeof n === "number")) {
      throw new TypeError("参数必须是数字");
    }
    return Reflect.apply(fn, thisArg, args);
  }
});

console.log(safeSum(2, 3)); // 5
// console.log(safeSum(2, "x")); // 抛错

14. 数据绑定与可观察对象

function observable(obj, callback) {
  return new Proxy(obj, {
    set(t, prop, value) {
      const result = Reflect.set(t, prop, value);
      callback(prop, value);
      return result;
    }
  });
}
const state = observable({ count: 0 }, (k, v) => {
  console.log(`${k} 更新为 ${v}`);
});
state.count++; // 输出: count 更新为 1

总结

  • Proxy 让我们可以拦截并自定义对象操作。
  • Reflect 提供与代理捕获器对应的标准 API,确保操作符合规范。
  • 常见用途包括:属性跟踪、隐藏敏感信息、数据验证、响应式编程。

CSS布局三巨头:浮动、定位与弹性布局的恩怨情仇

作者 日月晨曦
2025年8月15日 23:47

各位前端小伙伴们,今天咱们来聊聊CSS世界里的三位"布局大佬"。它们就像《西游记》里的师徒四人(哦不,这里只有三位),各有各的神通,也各有各的小脾气。掌握了它们,你就能在前端布局的世界里"横着走"啦!

一、浮动布局:曾经的王者,如今的"叛逆少年"

想当年,浮动布局那可是响当当的"布局一哥"。它最开始的使命特简单——让文字优雅地环绕图片,就像流水绕着石头走。可谁能想到,前端工程师们居然用它玩出了花,搞出了各种复杂的页面布局。

.box {
  float: left;  /* 往左飘 */
  width: 200px;
  height: 200px;
}

但这小子有个叛逆的毛病——脱离文档流。就像个调皮的孩子,一旦飘起来就不管不顾,亲爹(父容器)都感受不到它的重量(高度塌陷)。这可苦了我们这些当"保姆"的开发者,得想尽办法"清除浮动":

  • 给父容器手动定高度?太low了,不够灵活
  • 加个空div设置clear:both?代码洁癖表示无法接受
  • 最佳方案还是伪元素清除法,既干净又优雅:
.parent::after {
  content: "";
  display: block;
  clear: both;
}

对了,浮动还有个好兄弟叫BFC(块级格式化上下文)。这玩意儿就像给元素加了个"隔离罩",不仅能解决margin重叠问题,还能让父容器重新认识自己的浮动孩子(包含浮动元素高度)。开启BFC的方式有很多,比如overflow:hidden(最简单但小心隐藏内容)、position:absolute(一言不合就脱离文档流)、display:flex(直接叫来了更厉害的角色)。

二、定位布局:元素界的"轻功大师"

如果说浮动是叛逆少年,那定位布局就是身怀绝技的轻功大师。它有五种"轻功心法",咱们一个个来看:

  1. 静态定位(static):最普通的状态,元素规规矩矩按照文档流排队,就像上班族挤地铁,一步都不敢乱走。

  2. 相对定位(relative):有点小个性,可以相对于自己原来的位置偏移,但脚还踩在文档流里,不会影响别人。就像你在地铁上稍微挪了挪身子,但没离开自己的站位。

  3. 绝对定位(absolute):彻底放飞自我,脱离文档流,满世界找"靠山"(有定位属性的父元素)。如果没找到,就认body当爸爸。想让它水平垂直居中?简单:

.box {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);  /* 回退自身一半 */
}
  1. 固定定位(fixed):最任性的主儿,直接把浏览器窗口当自己家,不管页面怎么滚动,它都岿然不动。就像小区门口的保安,你走你的路,他站他的岗。

  2. 粘性定位(sticky):最善变的家伙,平时像relative一样老实,一旦你滚动到某个阈值,它立刻切换成fixed模式,死死黏在你指定的位置。就像你手机里的常用APP,平时藏在文件夹里,用的时候一点就出来。

三、弹性布局(Flexbox):布局界的"全能选手"

如果说前面两位是"老派高手",那弹性布局就是"新生代全能王"。它解决了前两者的诸多痛点,让布局变得简单、优雅、响应式。

弹性布局有两个核心角色:弹性容器弹性子元素。容器就像教练,子元素就是学员,教练说往哪排,学员就往哪排。

子元素的"生存法则"

  • 可以通过order属性改变排列顺序(数字越小越靠前,默认都是0)
  • flex-grow:控制是否放大(0不放大,1放大,数值越大占比越多)
  • flex-shrink:控制是否缩小(0不缩小,1缩小)
  • flex-basis:设置初始尺寸
  • 简写形式flex: 0 0 100px;(不放大、不缩小、初始100px)

容器的"指挥艺术"

  • justify-content: center;:子元素在主轴(默认水平)上居中
  • align-items: center;:子元素在交叉轴(默认垂直)上居中
  • flex-direction: column;:把主轴换成垂直方向
  • flex-wrap: wrap;:允许子元素换行(再也不用担心溢出了)
  • flex-flow: row wrap;flex-directionflex-wrap的简写
  • align-content: center;:有多行子元素时,控制整体在交叉轴上的对齐方式

有了弹性布局,什么两栏布局、三栏布局、居中对齐,都变得so easy!再也不用像以前那样写一堆浮动和清除代码了。

总结:三位大佬怎么选?

  • 如果你需要做文字环绕图片或者简单的水平排列,浮动布局依然是个不错的选择(记得清除浮动哦)。
  • 如果你需要精确定位元素(比如导航栏固定、弹窗居中),定位布局能帮你实现。
  • 如果你想做复杂的响应式布局(比如栅格系统、灵活的多栏布局),弹性布局绝对是首选,它会让你的代码更简洁、更易维护。

最后想说,CSS布局没有绝对的"银弹",每种布局方式都有它的适用场景。作为前端开发者,我们要做的就是了解它们的特性,在合适的场景用合适的布局,让我们的页面既美观又高效!

各位小伙伴,你们在布局时遇到过哪些有趣的问题?又是怎么解决的呢?欢迎在评论区分享你的故事!

❌
❌