普通视图

发现新文章,点击刷新页面。
今天 — 2025年7月2日首页

JavaScript 函数式编程思想与柯里化的深度剖析

作者 天涯学馆
2025年7月1日 22:39

JavaScript 作为一门多范式语言,既支持面向对象编程,也为函数式编程(Functional Programming, FP)提供了强大的支持。函数式编程以其声明式、不可变性和高阶函数等特性,正在现代前端开发中占据越来越重要的地位。而柯里化(Currying)作为函数式编程的核心技术之一,通过将多参数函数转换为单参数函数链,极大地提升了代码的灵活性和复用性。


1. 函数式编程的基础

1.1 什么是函数式编程?

函数式编程是一种编程范式,强调将计算过程建模为数学函数的求值,避免状态变化和可变数据。其核心思想包括:

  • 纯函数:函数的输出仅依赖于输入,且无副作用。
  • 不可变性:数据一旦创建不可修改,变化通过创建新数据实现。
  • 高阶函数:函数可以作为参数传递或作为返回值返回。
  • 声明式:关注“做什么”而非“怎么做”,代码更简洁。

在 JavaScript 中,函数是一等公民(First-Class Citizen),支持高阶函数、闭包等特性,使其天然适合函数式编程。

1.2 函数式编程的核心原则

  • 纯函数:相同的输入始终产生相同的输出,无副作用。例如:

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

    反例(非纯函数):

    let total = 0;
    function addToTotal(value) {
      total += value;
      return total;
    }
    
  • 不可变性:避免直接修改数据:

    const numbers = [1, 2, 3];
    const doubled = numbers.map(n => n * 2); // [2, 4, 6]
    console.log(numbers); // [1, 2, 3](原数组未变)
    
  • 避免副作用:函数不应修改外部状态:

    const log = console.log;
    function greet(name) {
      log(`Hello, ${name}`); // 副作用
      return `Hello, ${name}`;
    }
    
  • 函数组合:通过组合小函数实现复杂逻辑:

    const compose = (f, g) => x => f(g(x));
    const addOne = x => x + 1;
    const double = x => x * 2;
    const addOneThenDouble = compose(double, addOne);
    console.log(addOneThenDouble(5)); // 12
    

1.3 为什么在 JavaScript 中使用函数式编程?

  • 可预测性:纯函数和不可变性降低调试难度。
  • 模块化:高阶函数和函数组合提升代码复用性。
  • 并发友好:无状态操作更适合异步和并发场景。
  • 现代框架支持:React、Redux 等框架大量采用函数式思想。

2. 核心函数式编程概念

2.1 纯函数与副作用

纯函数是函数式编程的基石。实现纯函数需遵循:

  • 输入决定输出:不依赖外部变量。
  • 无外部修改:不更改全局状态或 DOM。

示例:

function filterEvens(numbers) {
  return numbers.filter(n => n % 2 === 0);
}
console.log(filterEvens([1, 2, 3, 4])); // [2, 4]

避免副作用:

// 非纯函数
let counter = 0;
function increment() {
  counter++;
  return counter;
}

// 纯函数
function incrementCounter(current) {
  return current + 1;
}

2.2 高阶函数

高阶函数接受函数作为参数或返回函数。JavaScript 的数组方法(如 mapfilterreduce)是典型的高阶函数。

const numbers = [1, 2, 3, 4];
const doubled = numbers.map(n => n * 2); // [2, 4, 6, 8]
const evens = numbers.filter(n => n % 2 === 0); // [2, 4]
const sum = numbers.reduce((acc, n) => acc + n, 0); // 10

自定义高阶函数:

function withLogging(fn) {
  return (...args) => {
    console.log(`Calling ${fn.name} with`, args);
    const result = fn(...args);
    console.log(`Result:`, result);
    return result;
  };
}

const add = (a, b) => a + b;
const loggedAdd = withLogging(add);
loggedAdd(2, 3);
// Calling add with [2, 3]
// Result: 5

2.3 闭包与函数式编程

闭包允许函数访问其定义时的词法作用域,是实现函数式编程的重要机制。

function createCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    get: () => count,
  };
}

const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.get()); // 1

闭包实现私有状态:

function createUser(name) {
  let _name = name;
  return {
    getName: () => _name,
    setName: newName => (_name = newName),
  };
}

const user = createUser('Alice');
console.log(user.getName()); // Alice
user.setName('Bob');
console.log(user.getName()); // Bob

2.4 不可变性

JavaScript 中的数组和对象是可变的,需通过复制实现不可变性。

数组不可变操作:

const numbers = [1, 2, 3];
const newNumbers = [...numbers, 4]; // [1, 2, 3, 4]
console.log(numbers); // [1, 2, 3]

对象不可变操作:

const user = { name: 'Alice', age: 30 };
const updatedUser = { ...user, age: 31 };
console.log(user); // { name: 'Alice', age: 30 }
console.log(updatedUser); // { name: 'Alice', age: 31 }

使用 Object.freeze

const config = Object.freeze({
  apiUrl: 'https://api.example.com',
  timeout: 5000,
});
config.apiUrl = 'new-url'; // 无效果
console.log(config.apiUrl); // https://api.example.com

2.5 函数组合与管道

函数组合通过将多个函数串联实现复杂逻辑。

组合(compose):

const compose = (...fns) => x => fns.reduceRight((y, f) => f(y), x);
const addOne = x => x + 1;
const double = x => x * 2;
const addOneThenDouble = compose(double, addOne);
console.log(addOneThenDouble(5)); // 12

管道(pipe):

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const addOneThenDouble = pipe(addOne, double);
console.log(addOneThenDouble(5)); // 12

3. 柯里化的核心概念

3.1 什么是柯里化?

柯里化是将一个多参数函数转换为一系列单参数函数的过程。形式上:

// 非柯里化
function add(a, b) {
  return a + b;
}

// 柯里化
function curriedAdd(a) {
  return function(b) {
    return a + b;
  };
}

const addFive = curriedAdd(5);
console.log(addFive(3)); // 8

3.2 柯里化的优势

  • 参数复用:固定部分参数,生成专用函数。
  • 延迟执行:只有提供所有参数时才执行计算。
  • 函数组合:柯里化函数易于组合。
  • 模块化:将复杂逻辑拆分为小函数。

3.3 手动实现柯里化

简单柯里化:

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args);
    }
    return (...nextArgs) => curried(...args, ...nextArgs);
  };
}

const add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6

3.4 处理任意参数

支持动态参数:

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args);
    }
    return (...nextArgs) => curried(...args, ...nextArgs);
  };
}

const join = (...args) => args.join('');
const curriedJoin = curry(join);

console.log(curriedJoin('a')('b')('c')); // abc
console.log(curriedJoin('a', 'b')('c')); // abc

3.5 占位符支持

实现带占位符的柯里化(如 Lodash 的 _.curry):

const _ = Symbol('placeholder');

function curry(fn) {
  const arity = fn.length;
  return function curried(...args) {
    const actualArgs = args.filter(arg => arg !== _);
    if (actualArgs.length >= arity) {
      return fn(...args.slice(0, arity));
    }
    return (...nextArgs) => {
      const combined = [];
      let argIdx = 0;
      let nextIdx = 0;
      for (let i = 0; i < args.length; i++) {
        if (args[i] === _ && nextIdx < nextArgs.length) {
          combined.push(nextArgs[nextIdx++]);
        } else {
          combined.push(args[i]);
        }
      }
      while (nextIdx < nextArgs.length) {
        combined.push(nextArgs[nextIdx++]);
      }
      return curried(...combined);
    };
  };
}

const add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);

console.log(curriedAdd(1, _, 3)(2)); // 6
console.log(curriedAdd(_, 2, _)(1, 3)); // 6

4. 柯里化在函数式编程中的应用

4.1 参数复用

柯里化通过固定参数生成专用函数:

const multiply = curry((a, b) => a * b);
const double = multiply(2);
const triple = multiply(3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

4.2 事件处理

为 DOM 事件创建专用处理器:

const addEventListener = curry((event, handler, element) =>
  element.addEventListener(event, handler)
);

const onClick = addEventListener('click');
const logClick = () => console.log('Clicked');

const button = document.querySelector('#myButton');
onClick(logClick)(button);

4.3 数据转换

处理数据管道:

const map = curry((fn, arr) => arr.map(fn));
const filter = curry((fn, arr) => arr.filter(fn));
const addOne = x => x + 1;
const isEven = x => x % 2 === 0;

const processNumbers = pipe(
  map(addOne),
  filter(isEven)
);

console.log(processNumbers([1, 2, 3, 4])); // [2, 4]

4.4 配置化函数

为函数注入配置:

const fetchData = curry((baseUrl, endpoint, params) =>
  fetch(`${baseUrl}/${endpoint}?${new URLSearchParams(params)}`).then(res =>
    res.json()
  )
);

const api = fetchData('https://api.example.com');
const getUsers = api('users');

getUsers({ limit: 10 }).then(console.log);

5. 函数式编程与柯里化在前端框架中的应用

5.1 React 中的函数式编程

React 的函数组件和 Hooks 天然契合函数式编程。

纯组件:

function UserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

高阶组件(HOC):

const withLoading = Component => ({ isLoading, ...props }) =>
  isLoading ? <div>Loading...</div> : <Component {...props} />;

const UserListWithLoading = withLoading(UserList);

function App() {
  const [users, setUsers] = useState([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setIsLoading(false);
      });
  }, []);

  return <UserListWithLoading isLoading={isLoading} users={users} />;
}

柯里化在 React:

const useFetch = curry((url, options, setState) =>
  useEffect(() => {
    fetch(url, options)
      .then(res => res.json())
      .then(setState);
  }, [url, options])
);

function UserList() {
  const [users, setUsers] = useState([]);
  const fetchUsers = useFetch('/api/users', { method: 'GET' });

  fetchUsers(setUsers);

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

5.2 Vue 中的函数式编程

Vue 3 的组合式 API 支持函数式风格。

纯函数组件:

import { defineComponent } from 'vue';

export default defineComponent({
  props: ['users'],
  setup({ users }) {
    return () => (
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    );
  },
});

高阶组件:

const withLoading = Component => ({
  props: ['isLoading'],
  setup(props) {
    return () => (props.isLoading ? <div>Loading...</div> : <Component {...props} />);
  },
});

const UserListWithLoading = withLoading(UserList);

柯里化:

const useFetch = curry((url, options, setState) => {
  onMounted(() => {
    fetch(url, options)
      .then(res => res.json())
      .then(setState);
  });
});

export default defineComponent({
  setup() {
    const users = ref([]);
    const fetchUsers = useFetch('/api/users', { method: 'GET' });

    fetchUsers(users.value);

    return () => (
      <ul>
        {users.value.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    );
  },
});

6. 函数式编程工具库

6.1 Ramda

Ramda 是一个专注于函数式编程的库,内置柯里化支持。

安装:

npm install ramda

使用:

import * as R from 'ramda';

const addOne = R.map(R.add(1));
const filterEvens = R.filter(x => x % 2 === 0);
const processNumbers = R.pipe(addOne, filterEvens);

console.log(processNumbers([1, 2, 3, 4])); // [2, 4]

柯里化:

const add = R.curry((a, b) => a + b);
const addFive = add(5);

console.log(addFive(3)); // 8

6.2 Lodash/fp

Lodash 的函数式模块提供柯里化支持。

安装:

npm install lodash

使用:

import { flow, map, filter, curry } from 'lodash/fp';

const addOne = map(x => x + 1);
const filterEvens = filter(x => x % 2 === 0);
const processNumbers = flow([addOne, filterEvens]);

console.log(processNumbers([1, 2, 3, 4])); // [2, 4]

const add = curry((a, b) => a + b);
const addFive = add(5);

console.log(addFive(3)); // 8

7. 性能优化

7.1 记忆化

通过缓存结果优化纯函数性能:

function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

const factorial = memoize(n => (n <= 1 ? 1 : n * factorial(n - 1)));

console.log(factorial(5)); // 120
console.log(factorial(5)); // 120(从缓存获取)

7.2 惰性求值

延迟计算以提升性能:

function lazyMap(fn, arr) {
  return {
    next: () => {
      const result = arr.map(fn);
      this.next = () => result;
      return result;
    },
  };
}

const numbers = [1, 2, 3, 4];
const lazyDouble = lazyMap(x => x * 2, numbers);

console.log(lazyDouble.next()); // [2, 4, 6, 8]
console.log(lazyDouble.next()); // [2, 4, 6, 8](缓存结果)

7.3 柯里化性能优化

缓存柯里化函数:

function curry(fn) {
  const cache = new Map();
  return function curried(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key);
    }
    if (args.length >= fn.length) {
      const result = fn(...args);
      cache.set(key, result);
      return result;
    }
    const next = (...nextArgs) => curried(...args, ...nextArgs);
    cache.set(key, next);
    return next;
  };
}

const add = curry((a, b, c) => a + b + c);
const addFive = add(5);

console.log(addFive(2)(3)); // 10
console.log(addFive(2)(3)); // 10(缓存)

8. 函数式编程与 TypeScript

8.1 纯函数

const add = (a: number, b: number): number => a + b;
console.log(add(2, 3)); // 5

8.2 高阶函数

type Fn<T, R> = (arg: T) => R;

function withLogging<T, R>(fn: Fn<T, R>): Fn<T, R> {
  return (...args: T[]) => {
    console.log(`Calling ${fn.name} with`, args);
    const result = fn(...args);
    console.log(`Result:`, result);
    return result;
  };
}

const add = (a: number, b: number) => a + b;
const loggedAdd = withLogging(add);
loggedAdd(2, 3);

8.3 柯里化

function curry<T extends any[], R>(fn: (...args: T) => R) {
  return function curried(...args: any[]): any {
    if (args.length >= fn.length) {
      return fn(...args);
    }
    return (...nextArgs: any[]) => curried(...args, ...nextArgs);
  };
}

const add = (a: number, b: number, c: number) => a + b + c;
const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3)); // 6

8.4 不可变性

interface User {
  readonly name: string;
  readonly age: number;
}

const user: User = { name: 'Alice', age: 30 };
const updatedUser: User = { ...user, age: 31 };

console.log(user); // { name: 'Alice', age: 30 }
console.log(updatedUser); // { name: 'Alice', age: 31 }

9. 函数式编程与异步编程

9.1 Promise 链

const fetchData = url =>
  fetch(url)
    .then(res => res.json())
    .then(data => data.results);

const processData = pipe(
  map(item => ({ ...item, processed: true })),
  filter(item => item.id > 0)
);

fetchData('/api/users')
  .then(processData)
  .then(console.log);

9.2 异步柯里化

const asyncCurry = fn => {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args);
    }
    return (...nextArgs) => curried(...args, ...nextArgs);
  };
};

const fetchData = async (url, params) =>
  fetch(`${url}?${new URLSearchParams(params)}`).then(res => res.json());

const curriedFetch = asyncCurry(fetchData);
const getUsers = curriedFetch('/api/users');

getUsers({ limit: 10 }).then(console.log);

10. 函数式编程与状态管理

10.1 Redux

Redux 使用纯函数(Reducer)管理状态:

const initialState = { count: 0 };

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'DECREMENT':
      return { ...state, count: state.count - 1 };
    default:
      return state;
  }
};

const increment = () => ({ type: 'INCREMENT' });
const decrement = () => ({ type: 'DECREMENT' });

柯里化 Action Creator:

const createAction = curry((type, payload) => ({ type, payload }));
const increment = createAction('INCREMENT');
const decrement = createAction('DECREMENT');

console.log(increment({ value: 1 })); // { type: 'INCREMENT', payload: { value: 1 } }

10.2 Zustand

Zustand 支持函数式状态管理:

import create from 'zustand';

const useStore = create(set => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 })),
  decrement: () => set(state => ({ count: state.count - 1 })),
}));

const increment = useStore(state => state.increment);
const decrement = useStore(state => state.decrement);

柯里化:

const createAction = curry((fn, set) => (...args) => set(state => fn(state, ...args)));

const increment = createAction((state, value) => ({
  count: state.count + value,
}));
const decrement = createAction((state, value) => ({
  count: state.count - value,
}));

const useStore = create(set => ({
  count: 0,
  increment: increment(set),
  decrement: decrement(set),
}));

11. 函数式编程与测试

11.1 测试纯函数

describe('add', () => {
  it('should add two numbers', () => {
    const add = (a, b) => a + b;
    expect(add(2, 3)).toBe(5);
  });
});

11.2 测试柯里化函数

describe('curriedAdd', () => {
  const add = curry((a, b, c) => a + b + c);
  it('should add three numbers', () => {
    expect(add(1)(2)(3)).toBe(6);
    expect(add(1, 2)(3)).toBe(6);
    expect(add(1)(2, 3)).toBe(6);
  });
});

11.3 Mock 高阶函数

jest.mock('./utils', () => ({
  withLogging: jest.fn(fn => (...args) => fn(...args)),
}));

describe('withLogging', () => {
  it('should wrap function', () => {
    const add = (a, b) => a + b;
    const loggedAdd = require('./utils').withLogging(add);
    expect(loggedAdd(2, 3)).toBe(5);
    expect(require('./utils').withLogging).toHaveBeenCalledWith(add);
  });
});

12. 函数式编程与模块化

12.1 CommonJS

// utils.js
module.exports = {
  curry: fn => {
    return function curried(...args) {
      if (args.length >= fn.length) {
        return fn(...args);
      }
      return (...nextArgs) => curried(...args, ...nextArgs);
    };
  },
};

// app.js
const { curry } = require('./utils');
const add = curry((a, b) => a + b);
console.log(add(2)(3)); // 5

12.2 ES Modules

// utils.mjs
export const curry = fn => {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args);
    }
    return (...nextArgs) => curried(...args, ...nextArgs);
  };
};

// app.mjs
import { curry } from './utils.mjs';
const add = curry((a, b) => a + b);
console.log(add(2)(3)); // 5

13. 函数式编程与微前端

13.1 模块化状态管理

const createModuleStore = curry((moduleName, initialState, reducers) =>
  create(set => ({
    ...initialState,
    ...Object.keys(reducers).reduce(
      (acc, action) => ({
        ...acc,
        [action]: (...args) =>
          set(state => reducers[action](state, ...args)),
      }),
      {}
    ),
  }))
);

const userStore = createModuleStore('user', { users: [] }, {
  addUser: (state, user) => ({ users: [...state.users, user] }),
});

userStore.getState().addUser({ name: 'Alice' });
console.log(userStore.getState().users); // [{ name: 'Alice' }]

13.2 Qiankun 微前端

import { registerMicroApps, start } from 'qiankun';

const createMicroApp = curry((name, config) => ({
  name,
  ...config,
}));

const registerApp = curry((apps, app) => {
  apps.push(app);
  return apps;
});

const apps = [];
const registerReactApp = registerApp(apps);
const createReactApp = createMicroApp('reactApp');

registerReactApp(
  createReactApp({
    entry: '//localhost:3001',
    container: '#reactContainer',
    activeRule: '/react',
  })
);

registerMicroApps(apps);
start();

14. 函数式编程与错误处理

14.1 Either 容器

实现简化的 Either 容器处理错误:

class Either {
  constructor(value, isRight = true) {
    this.value = value;
    this.isRight = isRight;
  }

  static Right(value) {
    return new Either(value, true);
  }

  static Left(value) {
    return new Either(value, false);
  }

  map(fn) {
    return this.isRight ? Either.Right(fn(this.value)) : this;
  }

  getOrElse(defaultValue) {
    return this.isRight ? this.value : defaultValue;
  }
}

const safeDivide = curry((a, b) =>
  b === 0 ? Either.Left('Division by zero') : Either.Right(a / b)
);

console.log(safeDivide(10)(2).getOrElse(0)); // 5
console.log(safeDivide(10)(0).getOrElse(0)); // 0

14.2 Try-Catch

const tryCatch = curry((fn, ...args) => {
  try {
    return Either.Right(fn(...args));
  } catch (error) {
    return Either.Left(error.message);
  }
});

const parseJSON = tryCatch(JSON.parse);

console.log(parseJSON('{"name":"Alice"}').getOrElse({})); // { name: 'Alice' }
console.log(parseJSON('invalid').getOrElse({})); // {}

15. 函数式编程与性能分析

15.1 性能测试

const numbers = Array.from({ length: 1000 }, (_, i) => i);

const start = performance.now();
numbers.map(x => x * 2).filter(x => x % 2 === 0);
const end = performance.now();
console.log(`Map and filter took ${end - start}ms`);

15.2 优化循环

使用 reduce 减少多次遍历:

const processNumbers = numbers =>
  numbers.reduce((acc, x) => {
    const doubled = x * 2;
    if (doubled % 2 === 0) {
      acc.push(doubled);
    }
    return acc;
  }, []);

const start = performance.now();
processNumbers(numbers);
const end = performance.now();
console.log(`Reduce took ${end - start}ms`);

16. 函数式编程与 Node.js

16.1 文件操作

const fs = require('fs').promises;

const readFile = curry((encoding, path) => fs.readFile(path, encoding));
const writeFile = curry((path, data) => fs.writeFile(path, data));

const processFile = pipe(
  readFile('utf8'),
  map(line => line.toUpperCase()),
  writeFile('output.txt')
);

processFile('input.txt');

16.2 HTTP 服务器

const http = require('http');

const createRoute = curry((method, path, handler) => ({
  method,
  path,
  handler,
}));

const handleRequest = curry((routes, req, res) => {
  const route = routes.find(
    r => r.method === req.method && r.path === req.url
  );
  if (route) {
    route.handler(req, res);
  } else {
    res.writeHead(404);
    res.end('Not Found');
  }
});

const routes = [
  createRoute('GET', '/users', (req, res) => {
    res.writeHead(200);
    res.end(JSON.stringify([{ name: 'Alice' }]));
  }),
];

const server = http.createServer(handleRequest(routes));
server.listen(3000);

17. 函数式编程与事件处理

const createEventHandler = curry((event, handler, element) =>
  element.addEventListener(event, handler)
);

const onClick = createEventHandler('click');
const logClick = () => console.log('Clicked');

const button = document.querySelector('#myButton');
onClick(logClick)(button);

18. 函数式编程与数据模型

const createModel = curry((transform, data) => transform(data));

const userModel = createModel(data => ({
  id: data.id,
  name: data.name,
  getFullName: () => data.name,
}));

const user = userModel({ id: 1, name: 'Alice' });
console.log(user.getFullName()); // Alice

19. 函数式编程与插件系统

const createPlugin = curry((name, fn) => ({
  name,
  apply: fn,
}));

const loggerPlugin = createPlugin('logger', config => message =>
  console.log(`[${config.level}] ${message}`)
);

const logger = loggerPlugin({ level: 'INFO' });
logger('System started'); // [INFO] System started

20. 函数式编程与配置管理

const createConfig = curry((env, config) => ({
  ...config,
  env,
}));

const devConfig = createConfig('development', {
  apiUrl: 'http://localhost:3000',
  debug: true,
});

console.log(devConfig); // { env: 'development', apiUrl: 'http://localhost:3000', debug: true }
昨天 — 2025年7月1日首页

Re: 0x01. 从零开始的光线追踪实现-光线、相机及背景

作者 壕壕
2025年6月30日 22:24

目标

书接上文,之前已经实现一个铺满整个窗口的红色填充,这趟来实现光线、相机及背景。

本节最终效果

image.png

计算物体在窗口坐标的位置

其实这个光追的思维模式很简单,就是从相机处开始发射一束射线,射线撞到哪些“物体”,就计算跟该“物体”相交的颜色。如图所示,从相机处发射射线,以左上角开始逐像素扫一遍,计算对应像素的颜色

fig-04-camera-view-space.svg

我们再看看 viewport 的坐标,假设一个窗口大小是 480272480 * 272(没错,PSP 的分辨率😁)的宽高,那么 xx 的区间就是 [0,479)[0, 479)yy 的区间就是 [0,271)[0, 271)

fig-03-viewport-coords.svg

现在我们要来处理一个标准化的像素坐标,处理像素在屏幕中的 2D 位置

struct Vertex {
  float4 position [[position]];
};

fragment float4 fragmentFn(Vertex in [[stage_in]]) {
  auto uv = in.position.xy / float2(float(480 - 1), float(272 - 1));
  // ...
}

上面这一步的作用是把像素级的屏幕坐标转成区间 [0,1][0, 1] 的归一化坐标。
假设现在有一个物体,它的坐标是 (240,135)(240, 135),通过上面的计算式子可以得出
uv=(240/479,135/271)(0.5,0.5)uv = (240 / 479, 135 / 271) ≈ (0.5, 0.5),说明它在屏幕的中间

接着我们假定相机的位置是原点 (0,0,0)(0, 0, 0),相机距离 viewport 11。我们计算出宽高的比例再套进这个计算 (2 * uv - float2(1)),等于讲把 [0,1][0, 1] 映射成 [1,1][-1, 1] 的范围,其实就是

原始 uv 变换后
(0, 0) (-1, -1) 左下角
(1, 0) (1, -1) 右下角
(0.5, 0.5) (0, 0) 居中
(1, 1) (1, 1) 右上角

再把 (2 * uv - float2(1))float2(aspect_ratio, -1) 相乘等于讲横向乘以 aspect_ratio 用来做等比例变换

至于纵向乘以 -1,那是因为在 Metal 中,yy 轴是向下为正,乘一下 -1 就可以把 yy 轴翻转变成向上为正,接下来计算方向就简单多了,因为 zz 轴面向相机,其实就是相机距离取反,上面假定相机距离为 1,所以取反再跟 uvuv 放一块就是方向,同时我们又假定相机的位置是原点 (0,0,0)(0, 0, 0),那么求光线就很容易了

struct Ray {
  float3 origin;
  float3 direction;
};

fragment float4 fragmentFn(Vertex in [[stage_in]]) {
  // ...
  const auto focus_distance = 1.0;
  // ...
  const auto direction = float3(uv, -focus_distance);
  Ray ray = { origin, direction };
}

现在既然有了光线,再就是要计算一下光线的颜色,因为目前场景中没有物体,所以就默认计算背景色,我们先把光线从 [1,1][-1, 1] 映射回 [0,1][0, 1],然后再线性插值计算渐变天空颜色,所以先要让光线经过归一化操作到 [1,1][-1, 1]

// [-1, 1]
normalize(ray.direction)

然后再给该向量加 11

// [-1, 1] + 1 = [0, 2]
normalize(ray.direction) + 1

然后把 [0,2][0, 2] 乘以 0.50.5 就转成 [0,1][0, 1] 了,之后再代入线性插值公式计算结果,具体渐变色值可以根据自己的需求调整,我这里直接使用 Ray Tracing in One Weekend 的色值 float3(0.5, 0.7, 1)

blendedValue=(1a)startValue+aendValueblendedValue = (1 − a) \cdot startValue + a \cdot endValue

float3 sky_color(Ray ray) {
  const auto a = 0.5 * (normalize(ray.direction).y + 1);
  return (1 - a) * float3(1) + a * float3(0.5, 0.7, 1);
}

最后总结一下代码

struct Ray {
  float3 origin;
  float3 direction;
};

float3 sky_color(Ray ray) {
  const auto a = 0.5 * (normalize(ray.direction).y + 1);
  return (1 - a) * float3(1) + a * float3(0.5, 0.7, 1);
}

fragment float4 fragmentFn(Vertex in [[stage_in]]) {
  const auto origin = float3(0);
  const auto focus_distance = 1.0;
  const auto aspect_ratio = 480 / 272;
  auto uv = in.position.xy / float2(float(480 - 1), float(272 - 1));
  uv = (2 * uv - float2(1)) * float2(aspect_ratio, -1);
  const auto direction = float3(uv, -focus_distance);
  Ray ray = { origin, direction };
  return float4(sky_color(ray), 1);
}

browser-tools-mcp前端开发调试利器

作者 dmy
2025年7月1日 21:54

如果你有过前端项目开发的经历,那么一定会经常打开浏览器自带开发者工具,查看网络请求或者控制台日志等等。遇到问题还会复制粘贴里面信息去搜索引擎搜索信息。即使当前ai非常强大,你也不得不手动告知ai你遇到的上下文情景。来来回回操作会非常繁琐,幸运的是这个mcp工具——browser-tools-mcp转为解决上面的问题而生。

怎么用呢?

1. 前提条件

由于是javascript开发,确保电脑上有安装node

2. step1: 浏览器安装插件

需要注意的是,如果你直接在chrome扩展商店是搜不到的,应该没上架。所以要手动去github.com/AgentDeskAI… 下载扩展安装包,解压后;在浏览器中通过打开开发者模式手动安装。

3. step2: 使用工具中添加mcp

假设你使用的是cursor,那么进入设置界面

image.png

添加添加New Mcp server配置如下信息

{
  "mcpServers": {
    "browser-tools": {
      "command": "npx",
      "args": ["@agentdeskai/browser-tools-mcp@latest"]
    }
  }
}

4. step3: 终端启动工具服务

image.png 这一步必不可少,它是一个中间服务,用于与你浏览器中的插件通信;启动后你也能看到些日志信息

5. step4: 直接使用

  1. 首先打开我们安装的插件
  2. 打开页面的开发者工具窗口

image.png 3. 在你的IDE中调用mcp即可获取到相关的调试信息

image.png 可以看到成功获取了浏览器开发工具中的信息。

最后

它的主要好处是打通了ai和浏览器调试的鸿沟,ai能直接获取到调试信息,大大加快代码调试速度。

从SSE到打字机——AI场景下前端的实现逻辑与实践

2025年7月1日 19:34

随着Deepseek的横空出世,让每个人都有了构建自己AI知识库的机会,作为一个前端开发者,完全可以通过大模型构建自己的日常学习知识库,然后自己写一个AI的交互页面构建自己的 ChatGPT ,当然说到这,肯定有人说现在有一键构建的开源项目为什么不用呢,说白了技术还是要自己实现才能更加深入地理解,并且更加灵活地运用到日常学习或者实际业务场景中去。

本篇文章只从前端的角度出发,分析实现一个AI的交互页面能用到哪些技术,最后再去实现一个AI场景页面。

当然,你也可以点击这里直接查看本篇文章实现的页面。

如果打不开,这里还有贴心国内服务器的备用链接

PS:上面两个演示链接都是用 vuepress 实现的个人博客,感觉用这套框架实现自定义组件里面的坑还挺多了,有机会可以再写一篇关于 vuepress 的开发避坑文章。

当然,关于IM的交互逻辑在我之前的文章 【从零开始实现一个腾讯IM即时通讯组件(无UI设计方案)~】中已经详细描述了实现过程,所以,这篇文章就从已经实现了IM交互的页面基础上开始实现AI场景下的IM。

技术选型

涉及到AI场景必然会联想到打字机效果的流式输出文本,那么前端实现这种效果有哪些方式呢?

协议对比

首先最简单的,通过轮询接口不断获取数据,其次通过websocket不断获取监听到的数据,最后通过服务端消息推送获取数据。这三种思路对应着三种通讯协议:HTTP、WebSocket、SSE。

先对比一下这三种协议:

基本概念与通信模式

特性 HTTP SSE (Server-Sent Events) WebSocket
协议类型 无状态的请求 - 响应协议 基于 HTTP 的单向事件流协议 基于 TCP 的全双工实时协议
通信方向 客户端→服务器(单向) 服务器→客户端(单向) 双向(全双工)
连接特性 短连接(每次请求新建连接) 长连接(单次请求,持续响应) 长连接(一次握手,持续通信)
发起方式 客户端主动请求 客户端主动请求,服务器持续推送 客户端发起握手,后续双向通信
典型场景 静态资源请求、API 调用 实时通知、股票行情、新闻推送 实时聊天、在线游戏、协作工具

技术细节对比

特性 HTTP SSE WebSocket
协议基础 HTTP/1.1 或 HTTP/2 HTTP/1.1 或 HTTP/2 WebSocket 协议 (RFC 6455)
端口 80 (HTTP) / 443 (HTTPS) 80/443 80 (ws) / 443 (wss)
数据格式 文本、JSON、二进制等 纯文本(text/event-stream) 文本或二进制(帧格式)
二进制支持 支持,但需额外处理 不支持(需编码为文本) 原生支持
自动重连 否(需客户端实现) 是(内置机制) 否(需手动实现)
心跳机制 否(需轮询) 否(需自定义) 是(Ping/Pong 帧)
浏览器兼容性 全兼容 现代浏览器(IE 不支持) 现代浏览器(IE 10+)

性能与效率

特性 HTTP SSE WebSocket
连接开销 高(每次请求需重新建立连接) 中(一次连接,长期保持) 低(一次握手,持续通信)
协议 overhead 高(HTTP 头信息冗余) 低(仅初始头) 中(帧头开销较小)
实时性 低(依赖客户端轮询) 高(服务器主动推送) 极高(双向实时)
带宽利用率 低(轮询导致无效请求) 中(单向持续传输) 高(按需双向传输)
延迟 高(请求响应周期) 中(推送延迟) 低(长连接直接通信)

API选择

再来回看一下我们的需求,AI场景说白了一问一答的方式,那么我们希望发送一次请求后,能够持续获取数据,本次请求后端也只需要知道我的问题即可,不需要和前端进行其他交互,所以 SSE 在这种场景下的优势就显而易见了。

前端要在浏览器中实现 SSE 的方式有两种:

  • EventSource API
  • fetch API

EventSourcefetch 都是现代 Web 开发中用于与服务器通信的 API。

特性 EventSource (SSE) Fetch API
通信模式 单向(服务器→客户端) 双向(请求→响应)
连接特性 长连接(持续接收服务器推送) 短连接(每次请求新建连接)
数据流类型 事件流(持续不断) 一次性响应(请求完成即结束)
数据格式 文本(事件流格式) 任意(JSON、Blob、文本等)
自动重连 内置支持(自动重连机制) 需手动实现

EventSource API实现了 SSE 。换句话说 EventSource API是 Web 内容与服务器发送事件通信的接口。一个EventSource 实例会对HTTP服务器开启一个持久化的连接,以 text/event-stream 格式发送事件,此连接会一直保持开启直到通过调用 EventSource.close() 关闭。

但是它有一些限制:

  • 无法传递请求体 request body ,必须将执行请求所需的所有信息编码到 URL 中,而大多数浏览器对 URL 的长度限制为 2000 个字符。
  • 无法传递自定义请求头。
  • 只能进行 GET 请求,无法指定其他方法。
  • 如果连接中断,无法控制重试策略,浏览器会自动进行几次尝试然后停止。

而AI场景常常会有一些其他需求,如上文记忆、接口 token 验证等等,于是 fetch 成了我们的最佳选择。

fetch API可以通过设置 headers 支持流式数据的接收,然后通过 ReadableStreamDefaultReader 对象,逐块读取响应的数据。

大模型选择

作为前端开发我们更注重于模型的定制化配置和页面的展示效果与交互,通过第三方模型可以快速满足我们的需求,这里我选用的是阿里云百炼

它直接提供了支持流式输出的接口,只需要在请求头加上 X-DashScope-SSE:true 。比较坑的是阿里云文档里面只提供了 node 的写法,实际浏览器中 axios 并不支持流式传输。

image-20250621145616487

API解析

AbortController

前面我们说到 SSE 的数据传输是单向的,有时候我们会想中断推送信息的接收,实际需求就是中断AI当前回答,所以我们需要一个控制器来更加精细地控制我们的请求。

AbortController 对象的作用是对一个或多个 Web 请求进行中止操作,像 fetch 请求、ReadableStream 以及第三方库的操作都可以取消。

核心机制:借助 AbortSignal 来实现操作的中止。AbortController 会生成一个信号对象,该对象可被传递给请求,当调用 abort() 方法时,就会触发请求的取消操作。

有了这个API我们就可以实现中断回答按钮的实现。

const controller = new AbortController()
const response = await fetch(
  url: 'url',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer token`,
      'X-DashScope-SSE': 'enable', // 允许实时推送
    },
    signal: controller.signal, // 信号对象绑定请求
    body: "{...}",
  }
)

setTimeout(()=> controller.abort(), 1000) // 一秒钟后自动中断请求

Reader

在请求发出后,我们需要一个能持续获取推送信息的入口,fetchresponse.body.getReaderJavaScript 中用于处理 fetch 请求响应流的方法,它允许你以可控的方式逐块读取响应数据,而非一次性处理整个响应。这在处理大文件下载、实时数据流(如视频、SSE)或需要渐进式解析数据的场景中特别有用。

// 获取一个 ReadableStreamDefaultReader 对象,用于逐块读取响应的二进制数据(Uint8Array)。
const reader = response.body.getReader()

while (true) {
  // 读取数据块 流是一次性的,读取完成后无法再次读取
const {done, value} = await reader.read();
  if (done) {
    console.log('流读取完成');
    break;
  }
}

循环调用 read() 以达到获取完整数据的需求,根据 done 判断是否已经读取完毕。

TextDecoder

TextDecoderJavaScript 中用于将二进制数据(如 ArrayBufferUint8Array)解码为人类可读的文字字符串的内置对象。它支持多种字符编码(如 UTF-8ISO-8859-1GBK 等),是处理网络响应、文件读取等二进制数据转换的标准工具。

// 任意二进制数据
const value = ...

// 流式解码:支持分块处理二进制数据(通过多次调用 decode 方法)。
const decoder = new TextDecoder('UTF-8')
// 解码二进制数据为文本
const chunk = decoder.decode(value, { stream: true })

值得注意的是 decodestream 参数设置为 true ,这是为了防止乱码的情况,因为我们知道 UTF-8 是一种变长编码,ASCII 字符(0-127)用 1 个字节表示,而其他字符(如中文、 emoji)可能用 2-4 个字节表示。例如:

  • 的 UTF-8 编码是 [228, 184, 150](3 个字节)。
  • 😊 的 UTF-8 编码是 [240, 159, 152, 138](4 个字节)。

当数据分块传输时,一个字符可能被截断在不同的块中。例如:

块1: [228, 184]    // "中" 的前两个字节(不完整)
块2: [150]         // "中" 的最后一个字节

stream 选项决定了解码器如何处理可能不完整的多字节字符:

stream 行为描述
false 默认值。假设输入是完整的,直接解码所有字节。若遇到不完整字符,会用 替换。
true 假设输入是数据流的一部分,保留未完成的多字节字符,等待后续数据。

实际情况可以参考下段代码:

// 错误情况
const decoder = new TextDecoder();
const chunk1 = new Uint8Array([228, 184]); // "中" 的前两个字节
const chunk2 = new Uint8Array([150]);      // "中" 的最后一个字节

console.log(decoder.decode(chunk1)); // 输出: "�"(错误:截断的字符被替换为乱码)
console.log(decoder.decode(chunk2)); // 输出: "�"(错误:单独的第三个字节无法组成有效字符)

// 正确情况

const decoder = new TextDecoder();
const chunk1 = new Uint8Array([228, 184]); // "中" 的前两个字节
const chunk2 = new Uint8Array([150]);      // "中" 的最后一个字节

console.log(decoder.decode(chunk1, { stream: true })); // 输出: ""(无输出,保留未完成字符)
console.log(decoder.decode(chunk2));                   // 输出: "中"(合并后正确解码)

处理流式输出

结合上述API的分析,fetch 实现处理流式数据的代码如下:

const controller = new AbortController()

const response = await fetch(
  url,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer sk-dd0e8892eb0445149fd21fd9b1d6176c`,
      'X-DashScope-SSE': 'enable',
    },
    signal: controller.signal,
    body: JSON.stringify({
      input: {
        prompt: text
      },
      parameters: {
        'incremental_output' : 'true' // 增量输出
      },
    }),
  }
)
if (!response.ok) {
  message.error('AI返回错误')
  loadingSend.value = false
  return
}

const decoder = new TextDecoder('UTF-8')
const reader = response.body.getReader()

while (true) {
  const {done, value} = await reader.read();

  if (done) {
    console.log('流读取完成');
    // 中断fetch请求
    controller.abort()
    // 资源释放:释放读取器锁
    reader.releaseLock()
    break;
  }

  // 解码二进制数据为文本
  const chunk = decoder.decode(value, { stream: true })
  console.log('chunk:===>', chunk)
}

处理流式数据

通过 reader 读取到的数据经过 decoder 处理后格式如下:

id:1
event:result
:HTTP_STATUS/200
data:{"output":{"session_id":"0837b503363c4525a6609f868f3f6afa","finish_reason":"null","text":"我是","reject_status":false},"usage":{"models":[{"input_tokens":370,"output_tokens":1,"model_id":"deepseek-v3"}]},"request_id":"ecea2ce7-3867-9074-aa67-92b39ba9253a"}

id:2
event:result
:HTTP_STATUS/200
data:{"output":{"session_id":"0837b503363c4525a6609f868f3f6afa","finish_reason":"null","text":"你的","reject_status":false},"usage":{"models":[{"input_tokens":370,"output_tokens":2,"model_id":"deepseek-v3"}]},"request_id":"ecea2ce7-3867-9074-aa67-92b39ba9253a"}

当然这个是阿里云的返回格式,但流式数据格式都大差不差,接下来我们来分析这段文本。

首先,reader 获取的数据可能会有多段,如上文中的就是 id:1id:2 两段数据。

其中关键字段为:data.output.text ,所以我们需要根据返回数据的结构特点通过正则把有效信息给过滤出来。

// 全局贪婪匹配 "text":" 到 ","reject_status": 之间的内容,确保多段数据也能准确提取所有的有效信息
const regex = /"text":"(.*?)","reject_status":/gs;

这里使用正则而不是 JSON 化的原因是流式数据的处理讲究高效与准确JSON 化更加地消耗性能,而且存在异常报错的可能,为了最大可能保证主流程的持续输出,用正则是更优的选择。当然具体业务场景具体处理,这里仅作个人见解。

根据上述正则,实现一个数据处理函数:

const extractText = (jsonString) => {
  try {
    const regex = /"text":"(.*?)","reject_status":/gs;
    let match;
    let result = '';
    // 利用regex.exec()在字符串里循环查找所有匹配结果,把每次匹配得到的捕获组内容(也就是text字段的值)添加到result字符串中。
    while ((match = regex.exec(jsonString)) !== null) {
      // 将字符串里的\n转义序列转换为真正的换行符,把\"转义序列还原为普通的双引号。
      result += match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
    }
    return result
  } catch (error) {
    console.log('error', error)
    return ''
  }
}

最后把数据处理函数加到流式输出代码中,通过缓存持续获取有用的信息:

...
// 用于累计接收到的数据
let accumulatedText = ''

while (true) {
  const {done, value} = await reader.read();

  if (done) {
    ...
    break;
  }

  const chunk = decoder.decode(value, { stream: true })
  // 累加并渲染数据
  const newText = extractText(chunk)
  if (newText) {
  accumulatedText += newText
  }
}

转换MD文本

这里用到几个库来实现:

  • markdown-it 一个快速、功能丰富的 Markdown 解析器,基于 JavaScript 实现。它的主要作用是把 Markdown 文本转换成 HTML。
  • @vscode/markdown-it-katex VS Code 团队开发的插件,用于在 Markdown 中渲染 LaTeX 数学公式,它集成了 KaTeX 这个快速的数学公式渲染引擎。
  • markdown-it-link-attributes 为 Markdown 中的链接添加自定义属性,比如为外部链接添加target="_blank"rel="noopener noreferrer"属性。
  • mermaid-it-markdown 用于在 Markdown 中集成 Mermaid 图表,Mermaid 是一种用文本语法描述图表的工具。

三方库使用

结合上述各种库结合,处理接口返回的信息流:

import MarkdownIt from 'markdown-it'
import MdKatex from '@vscode/markdown-it-katex'
import MdLinkAttributes from 'markdown-it-link-attributes'
import MdMermaid from 'mermaid-it-markdown'
import hljs from 'highlight.js'

const mdi = new MarkdownIt({
  html: false,
  linkify: true,
  highlight(code, language) {
    const validLang = !!(language && hljs.getLanguage(language))
    if (validLang) {
      const lang = language ?? ''
      return highlightBlock(hljs.highlight(code, { language: lang }).value, lang)
    }
    return highlightBlock(hljs.highlightAuto(code).value, '')
  },
})
mdi.use(MdLinkAttributes, { attrs: { target: '_blank', rel: 'noopener' } }).use(MdKatex).use(MdMermaid)

// 实现代码块快速复制
function highlightBlock(str, lang) {
  return `<pre class="code-block-wrapper">
            <div class="code-block-header">
                <span class="code-block-header__lang">${lang}</span>
                <span class="code-block-header__copy">复制代码</span>
            </div>
            <code class="hljs code-block-body ${lang}"><br>${str}</code>
          </pre>`
}

const renderToAI = (text) => {
  // 对数学公式进行处理,自动添加 $$ 符号
  const escapedText = escapeBrackets(escapeDollarNumber(text))
  return mdi.render(escapedText)
}

const escapeBrackets = (text) => {
  const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\]|\\\((.*?)\\\)/g
  return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => {
    if (codeBlock)
      return codeBlock
    else if (squareBracket)
      return `$$${squareBracket}$$`
    else if (roundBracket)
      return `$${roundBracket}$`
    return match
  })
}

const escapeDollarNumber = (text) => {
  let escapedText = ''

  for (let i = 0; i < text.length; i += 1) {
    let char = text[i]
    const nextChar = text[i + 1] || ' '

    if (char === '$' && nextChar >= '0' && nextChar <= '9')
      char = '\\$'

    escapedText += char
  }

  return escapedText
}

复制代码块

快速复制代码实现:

// 聊天列表主体元素
const textRef = ref()

// 构建textarea,将内容复制到剪切板
const copyToClip = (text) => {
  return new Promise((resolve, reject) => {
    try {
      const input = document.createElement('textarea')
      input.setAttribute('readonly', 'readonly')
      input.value = text
      document.body.appendChild(input)
      input.select()
      if (document.execCommand('copy'))
        document.execCommand('copy')
      document.body.removeChild(input)
      resolve(text)
    }
    catch (error) {
      reject(error)
    }
  })
}

// 为所有的复制代码按钮添加复制事件
const addCopyEvents = () => {
  if (textRef.value) {
    const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
    copyBtn.forEach((btn) => {
      btn.addEventListener('click', () => {
        const code = btn.parentElement?.nextElementSibling?.textContent
        if (code) {
          copyToClip(code).then(() => {
            btn.textContent = '复制成功'
            setTimeout(() => {
              btn.textContent = '复制代码'
            }, 1000)
          })
        }
      })
    })
  }
}

// 移除页面中所有的复制事件
const removeCopyEvents = () => {
  if (textRef.value) {
    const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
    copyBtn.forEach((btn) => {
      btn.removeEventListener('click', () => { })
    })
  }
}

// 在合适的生命周期里注册或卸载重新事件

// 可以在流式输出完成,页面渲染完成的时候手动调用,避免性能浪费,更加合理
onUpdated(() => {
  addCopyEvents()
})

onUnmounted(() => {
  removeCopyEvents()
})

自定义MD样式

MD样式:

.ai-message {
    background-color: transparent;
    font-size: 14px;
}
.ai-message p {
    white-space: pre-wrap;
}
.ai-message ol {
    list-style-type: decimal;
}
.ai-message ul {
    list-style-type: disc;
}
.ai-message pre code,
.ai-message pre tt {
    line-height: 1.65;
}
.ai-message .highlight pre,
.ai-message pre {
    background-color: #fff;
}
.ai-message code.hljs {
    padding: 0;
}
.ai-message .code-block-wrapper {
    position: relative;
    padding: 0 12px;
    border-radius: 8px;
}
.ai-message .code-block-header {
    position: absolute;
    top: 5px;
    right: 0;
    width: 100%;
    padding: 0 1rem;
    display: flex;
    justify-content: flex-end;
    align-items: center;
    color: #b3b3b3;
}
.ai-message .code-block-header__copy {
    cursor: pointer;
    margin-left: 0.5rem;
    user-select: none;
}
.ai-message .code-block-header__copy:hover {
    color: #65a665;
}
.ai-message div[id^='mermaid-container'] {
    padding: 4px;
    border-radius: 4px;
    overflow-x: auto !important;
    background-color: #fff;
    border: 1px solid #e5e5e5;
}
.ai-message li {
    margin-left: 16px;
    box-sizing: border-box;
}

最后,把处理函数追加到处理流式数据后面:

let mdHtml = ''

...
const chunk = decoder.decode(value, { stream: true })
const newText = extractText(chunk)
if (newText) {
  accumulatedText += newText
  mdHtml += renderToAI(accumulatedText)
}

打字机

到目前为止我们已经流式地拿到了接口返回的数据并且转换成了页面可以展示的MD风格HTML字符串。

打字机的基本思路就是按照一定频率把内容添加到页面上,并且在内容最前面加个打字的光标。

直接上代码:

<template>
  <div v-html="displayText + `${ showCursor || adding ? `<span class='cursors'>_</span>`:'' }`"></div>
</template>

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

const props = defineProps({
  // 要显示的完整文本
  text: {
    type: String,
    required: true
  },
  // 打字速度(毫秒/字符)
  speed: {
    type: Number,
    default: 10
  },
  showCursor: {
    type: Boolean,
    default: false
  },
  break: {
    type: Boolean,
    default: false
  },
});
const emits = defineEmits(['update', 'ok'])

const displayText = ref('');
const adding = ref(false);
let timer = null;

// 更新显示的文本
const updateDisplayText = () => {
  if (displayText.value.length < props.text.length) {
    adding.value = true;
    displayText.value = props.text.substring(0, displayText.value.length + 1);
    emits('update')
    timer = setTimeout(updateDisplayText, props.speed);
  } else {
    adding.value = false;
    setTimeout(() =>{
      emits('ok')
    } ,600)
  }
};

// 增量更新
watch(() => props.text, (newText) => {
  // 如果新文本比当前显示的文本长,则继续打字
  if (newText.length > displayText.value.length) {
    clearTimeout(timer);
    updateDisplayText();
  }
});

// 停止回答
watch(() => props.break, (val) => {
  if (val) {
    displayText.value = props.text + ''
    clearTimeout(timer);
    adding.value = false;
    setTimeout(() =>{
      emits('ok')
    } ,600)
  }
});

// 初始化
updateDisplayText();

// 组件卸载时清理定时器
onUnmounted(() => {
  clearTimeout(timer);
});
</script>

<style>

.cursors {
  font-weight: 700;
  vertical-align: baseline;
  animation: blink 1s infinite;
  color: #3a5ccc;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}
</style>  

我们只需要把上述转换的MD文本传入这个组件就能实现打字机效果。

<temlate>
<div class="ai-message">
    <TypingEffect :text="text" :showCursor="!ready" :break="break" @update="updateAIText" @ok="textAllShow" />
  </div>
</temlate>

需要注意的是,打字机打印的速度是按照恒定速度执行的,流式数据是不规则时间返回的,有可能返回很快,也有可能返回很慢,所以两边就会有时间差。

这就造成了一种现象,有时候我们点了停止回答的按钮,页面上还在不断输出内容,好像没有打断这次回答,这里我们只需要在点击停止回答的时候终止打字机的轮询,直接展示完整数据即可。

最后优化显示,需要自动滚动到底部:

const scrollToBottom = () => {
  try {
    const { height } = textRef.value.getBoundingClientRect()
    textRef.value.scrollTo({
      top: textRef.value.scrollHeight - height,
      behavior: 'smooth',
    })
  } catch (e) {}
}

总结

前端AI场景下总结来说就两个平时不常见的技术点:

  • 流式输出
  • 请求中断

当然本篇文章只是实现了基本的AI场景,像上下文记忆、多对话框以及更大模型的微调等等并未涉及到,这些更加深入地功能,可以后面慢慢研究,那么,这次就到这里~

上汽名爵一口气发了三辆车,其中一辆会让 OPPO 帮着卖

作者 芥末
2025年7月1日 19:30


在比亚迪 2024 年登顶之前,上汽在自主品牌销冠的位置上坐了 18 年。

燃油车时代的老大自然不甘心在新能源的浪潮中默默掉队。2024 年 7 月,上汽集团原董事长陈虹到龄退休,由王晓秋接任董事长,贾建旭接任总裁,然后开启了上汽在新时代的大变革。

在 24 年 8 月的一次内部会议上,贾建旭讲的很直接:

学会「跪着做人」,才能有站起来的那天。

上汽内部最有年轻感的 MG 名爵品牌,就是上汽开始「跪着做人」的一个缩影。

「OPPO 汽车」要来了?

MG 目前的总体境况是国外销量和国内销量差了几个数量级。

2024 年 MG 品牌的整体销量超过了 70 万辆,其中欧洲市场卖了 24 万辆,国内销量仅有 7.4 万辆,但是由于地缘政治、关税等各种原因,MG 的总体销量下滑了 16.7%。

因此如何更好的抓住国内市场,就成了上汽要重点考虑的问题。

在经过了几乎一年的内部变革和调整后,名爵终于给出了他们的解题思路——智能化、年轻化,以及三款新车——全新 MG4、MG5 以及 Cyberster。

名爵在智能化上吃过很大的亏。之前的名爵 MG7 就因为车机生态封闭、导航软件落后、操作逻辑反人类等缺点被车主联合维权,造成了不小的负面影响。

于是名爵这次痛定思痛,和手机大厂 OPPO 联手,在全新的 MG4 车型上实现了 OPPO 在车机互联方面的全部能力,比如通过手机语音备车、手车无感互联、摇一摇导航等功能,甚至在营销和渠道上也要和 OPPO 深度合作,让名爵品牌的车型可以通过 OPPO 渠道完成车辆的定价和销售。

名爵 MG4 这辆车则暂时只发布了官图,总体上还是延续了 Cyberster 的前脸设计语言,走的还是运动风的路子,下格栅等部件也沿用了 MG 家族标志性的中轴线设计,更多具体的车辆信息则要等到 8 月份车辆上市了。

「大鲨鱼」MG5

2026 款的名爵 MG5 则属于是年度小改款的车型,各方面的变化并不大,价格十分亲民,官方指导起售价为 8.19 万元,在叠加官方提供的置换补贴之后,起售价可以降到 5.99 万元,在紧凑型轿车里面算是价格最低的那一档。

2026 款 MG5 的车身尺寸为 4715×1842×1473mm,轴距为 2680mm,整体车身设计延续了 MG 品牌标志性的「猎鲨」设计语言和溜背造型。新车前脸采用了超大尺寸的运动直瀑式格栅以及刀锋状的 LED 大灯组,视觉冲击感很强,车辆整体的姿态则略微呈现出一种俯冲姿态,增强了车辆的运动属性。

新车的内饰就几乎与现款车型保持一致了,采用橙黑双拼色的设计,座椅、方向盘以及中控台大面积使用了软质材料包覆。一体式运动座椅则采用了防滑皮质面料,提供了很强的侧翼支撑性。

MG5 配备了 12.3 英寸悬浮式中控屏与 12.3 英寸全液晶仪表盘,搭载的是维纳斯智能车机系统,支持车联网、4G网络、语音连续识别等功能,部分高配车型上则提供了256色氛围灯、自动空调等高阶配置。

辅助驾驶方面,2026 款 MG5 搭载了 4 个摄像头以及 3 个超声波雷达,支持内置行车记录仪、360 度全景影像、透明底盘、定速巡航等驾驶辅助功能。

2026 款 MG5 提供两种动力选择,一款为 1.5L + CVT 的无级变速箱,另一款是 1.5T + 7 挡湿式双离合变速箱,后面这款 1.5T 发动机是上汽自主全新设计的,拥有着全部的自主设计与专利,最大马力为 181 匹,峰值扭矩285N·m。底盘配置部分,新车则采用了前麦弗逊加后扭力梁的非独立悬架系统。

玩乐型的 MG Cyberster

名爵旗下的双门双座敞篷跑车 MG Cyberster 也迎来了年度改款。新车一共有 3 款配置,指导价分别为 31.98 万元、33.98 万元和 35.98 万元。

2026 款的 MG Cyberster 的长宽高分别为 4535mm×1913mm×1329mm,轴距为 2690mm,造型则基本延续了现款的车型设计,整体采用了流线动感的设计风格,保留了剪刀门,偏流线型的车头以及前包围搭配上跑车风格的大灯组以及箭头型的尾灯组,车辆整体的「玩乐」性质体现的很充分。

改款车型在颜色上新增了鸢尾青和安第斯灰两款外观配色,并且开放了红色软蓬的选装,也配备了带定风翼的侧翼子板、无边框车门、20英寸多辐式轮毂和红色刹车卡钳。

MG Cyberster 的内饰新增了黑红、灰白两种内饰颜色以及电动腰托和车内挡风板,后备厢也进行了扩大,能够放下 24 寸的行李箱;车辆智能化的部分则采用了 7 + 10.25 + 7 英寸的三联屏组合,搭载了斑马智能座舱系统 Cyber OS 系统,内置的是高通骁龙 8155 芯片,并且新增了敞篷计数器功能,可以看到敞篷的开合次数和行驶距离等。

256 色氛围灯、麂皮材质运动座椅、前排座椅加热、8 扬声器 BOS E音响系统、L2 级辅助驾驶系统、360 度全景影像等周边配置在 MG Cyberster 也一应俱全。

动力方面,2026 款 MG Cyberster 提供单电机后驱和双电机四驱两种版本可选,其中后驱版电动机最大功率为 231kW 和 250kW,峰值扭矩为 475 N·m,百公里加速 4.9s,最高车速 193km/h。双电机四驱版车型系统综合最大功率为 400kW,峰值扭矩为 725N·m,百公里加速时间 3.2s。

续航上,单电机后驱提供了 64kWh 和 77kWh 的两种电池组,CLTC 纯电续航分别为 501km 和580km,双电机四驱版搭载的是 77kWh 的三元锂电池组,续航里程为 520km。

不过 MG Cyberster 对上汽名爵来说更多的像是一款「吉祥物」般的车型,整个 2024 年累计只卖出了 690 辆,对于整个名爵营收的影响也微乎其微。

总的来说,名爵这次发布的三款车,MG5 和 Cyberster 都算是常规换代,真正会影响到名爵未来的可能还是 8 月份的 MG4 以及和 OPPO 的渠道合作。

和目前涉及汽车业务的小米和华为不同,OPPO 之前向来以极强的线下渠道能力著称,上汽这次选择与 OPPO 合作也看看中了其在品牌建设、公关传播和新媒体等方面的积累和沉淀。

上汽在发布会上也明确提到,双方正在积极探索「汽车+智能终端」的跨界合作渠道创新模式,全新 MG4 也即将入驻 OPPO 全国商超体验店及快闪店。

那这会不会是 OPPO 的一次提前试水呢?未来某一天,或许会有「OPPO 汽车」出现嘛?

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


Maxell 发布复古磁带机:能听磁带,支持蓝牙,还能当充电宝

作者 周芊彤
2025年7月1日 17:52

2025 年了,怎么还有崭新的磁带机上市?

其实和 CCD、胶片、撕拉片等一个意思,磁带机也属于这两年「复古回潮」的一个小众赛道。一些小的海外创业公司,以及中国本土的一些还在提供磁带机零部件产能的工厂,都在借着这股势头,建立品牌,推出产品。

现在,终于有(算是)巨头品牌入局了。

6 月 30 日,日本老牌消电企业 Maxell 推出了最新款的磁带播放器 Maxell MXCP-P100,支持蓝牙音频,售价为 13000 日元(约合人民币 648 元)。

作为一款仅支持单一媒介的复古播放器,这个价格算不上便宜。

▲ Maxell MXCP-P100. 图片来自:Maxell

Maxell 创立于上世纪 60 年代,今天更多业务在电池、存储、光学、耳机、投影仪等市场。在音乐行业还没有被在线流媒体抢占,模拟音乐和随身听仍然称霸的二、三十年前,Maxell 作为最大的磁带厂商之一,好歹也能算个音乐技术巨头。

说回这台新款磁带机:MXCP-P100 有着传统磁带播放器的外观造型,简约的黑白配色和圆润的倒角,有种盛田昭夫时代的索尼+包豪斯设计理念集于一身的感觉——怎么说,很 90 年代。

▲ Maxell MXCP-P100 的两种颜色. 图片来自:Maxell

MXCP-P100 尺寸为 122 × 91 × 38mm ,重 210g,跟手机差不多,除了更厚一点。采用机械按键进行操控——播放、快进、倒带等按键分明、一应俱全。背后的边夹设计,可以让你方便地别在身上,是时尚单品无误了。

在磁带机流行的年代,手机还是个大黑粗的稀罕货。如今,集成度更高的手机已经比随身听还要小。而这支新款磁带机的尺寸,反而比鼎盛时期的索尼随身听要厚出不少。

你可能也有疑问:为什么 20 年前风靡一时的索尼随身听,现在反而复刻不出来?答案其实在于磁带早已成为夕阳产业,随身听的零部件早已停产,现在供应链只有这种更加厚重的公版方案——这也是无论 Maxell,还是前面提到的中国工厂、海外创业品牌,都无法复刻索尼随身听的核心原因。

▲ Maxell MXCP-P100 佩戴效果. 图片来自:Maxell

究其根本,当代人已经不再需要磁带机,只是爱怀旧而已。

于是,MXCP-P100 在兼容 90 分钟磁带的同时,摒弃了多余的录音功能,聚焦于最核心的听歌体验。不仅配备了 3.5mm 耳机孔,还能支持蓝牙 5.4 双模输出,你可以选择用耳机线连,也可以通过蓝牙将磁带音频无线传输到耳机或音响上。

公版磁带机的抖动问题,也在这台 MXCP-P100 上通过改装黄铜飞轮的方式得到解决。这能够让磁带播放更加稳定,避免速度不稳带来的失真。

▲ Maxell MXCP-P100. 图片来自:Maxell

续航方面,MXCP-P100 的表现还不错,支持 9 小时的有线续航和 7 小时的蓝牙续航,配备 USB-C 充电接口,2 小时能充满电。

以及,可能是除了蓝牙之外最有用的功能:你可以把它当充电宝,给手机反向充电。

13000 日元,买个能放磁带的充电宝——这样听起来是不是真的很值?

▲ Maxell MXCP-P100. 图片来自:Maxell

尽管设计和功能都相对简单,但 Maxell MXCP-P100 却精准地传递了那种属于磁带播放器的怀旧气息,适度怀旧一下,确实相当便利——技术不能只用于淘汰过去,也应该让过去更好地融入现在。

赶巧的是,就在这部支持蓝牙、能当充电宝的磁带机上市的当天,Apple Music 也迎来了十周年——流媒体音乐显然大获全胜了。

但 2025 年的磁带机过得也不错,刚刚上 Maxell 官网看了眼——已经卖光了!在北美市场,由于缺乏 Maxell 授权经销商,在线市场的炒价更是一度高达 250 美元。

▲ Maxell MXCP-P100 售罄页面. 图片来自:Maxell

如果 250 美元买个磁带机,确实挺 250 的。就像所有的复古回潮硬件那样,黄牛早已盯紧这个市场。作为消费者的你需要保持理智:你不需要这个东西,虽然它真的挺好玩的。

本文作者:周芊彤、肖钦鹏

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


如何在markdown中,将JSON渲染成自定义React组件

作者 乐悠悠2
2025年7月1日 17:40

背景:

  • AI助手返回的markdown格式文本,渲染成markdown文件,并将代码高亮展示
  • markdown格式文本中JSON配置,按照自定义组件样式展示

markdown展示图

image.png

image.png

image.png

将JSON渲染成自定义组件效果图

image.png

解决思路

  • 引入marked、marked-highlight、highlight.js渲染markdown格式文本
  • 使用react-dom/server的ReactDOMServer将React组件渲染成静态HTML标签
  • 代码块添加按钮、事件

详细实现

import { useEffect, useState } from 'react'
import ReactDOMServer from 'react-dom/server';
import { Button, message } from 'antd';
// 自定义组件
import StaticDraggerTree from '@/components/DraggerTree/StaticTree'
import { Provider } from 'react-redux';
import { Marked, Renderer } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import 'highlight.js/styles/base16/darcula.css'

import './index.less';

const mockStore = {
  subscribe: () => {},
  dispatch: () => {},
  getState: () => ({}),
};
 

const AIDialogue = (props) => {

  const { content } = props;
  const [parseHtml, setParseHtml] = useState('')

  function removeCodeBlockMarker(code) {
    return code.replace(/```[\w\s-]*\n([\s\S]+?)\n```/g, '$1');
  }

  const renderer = new Renderer()
  renderer.code = function (code, lang) {
    const highlightedCode = hljs.highlightAuto(removeCodeBlockMarker(code.raw)).value;

    // 判断是否是需要渲染成自定义组件的配置json,如果是,需将json转换成自定义组件的输入格式
    let isServiceOrch = false;
    try{ // content为流式文件返回时,添加try catch防止JSON不完整时解析报错
      if (code.lang == 'json') {
        // 解析成JSON对象
        const dataJson = JSON.parse(removeCodeBlockMarker(code.raw));
        if (dataJson) { // 具体为JSON满足的自定义渲染条件
          isServiceOrch = true;
        }
      }
    }catch(e){
      console.log(e)
    }
    
    if (isServiceOrch) {
      const randomId = Math.random().toString(36).substring(2, 10)
      const jsonObj = JSON.parse(removeCodeBlockMarker(code.raw));
      const children = jsonObj.children || jsonObj.defaultConfig?.children;
      const reactEle = ReactDOMServer.renderToString(
        <div class="mermaid-block" data-mermaid-id={randomId}>
          <Provider store={mockStore}>
            <StaticDraggerTree nodeTreeData={{children}} />
          </Provider>
          <Button class="so-service-action" data-action="create" data-mermaid-id={randomId}>
            创建编排
          </Button>
          <div class="mermaid-code-view" id={`mermaid-code-${randomId}`}>
            {/* 大模型生成的原始json,创建编排时使用。此处不需展示,故隐藏 */}
            <pre style={{"display": "none"}}><coderaw class="hljs language-mermaid">{removeCodeBlockMarker(code.raw)}</coderaw></pre>
          </div>
        </div>
      );
      return `${reactEle}`
    } 
    else {
      if (code.lang) {
        return `<pre><code class="hljs language-${code.lang}">${highlightedCode}</code></pre>`
      } else {
        return `<pre><code class="hljs">${highlightedCode}</code></pre>`
      }
    }
  }

  const marked = new Marked(
    markedHighlight({
      langPrefix: 'hljs language-',
      highlight(code, lang) {
        const language = hljs.getLanguage(lang) ? lang : 'plaintext'
        return hljs.highlight(code, { language }).value
      }
    })
  )
  marked.use({
    extensions: [
      {
        name: 'thinkBlock',
        level: 'block',
        start(src) {
          return src.match(/<think>/i)?.index
        },
        tokenizer(src) {
          const rule = /^<think>([\s\S]*?)<\/think>/i
          const match = rule.exec(src)
          if (match) {
            return {
              type: 'thinkBlock',
              raw: match[0],
              text: match[1].trim()
            }
          }
        },
        renderer(token) {
          return `<think>${token.text}</think>`
        }
      }
    ]
  })
  marked.setOptions({
    renderer,  //默认render,如需特殊配置,可打开注释
    highlight: function (code) {
      return hljs.highlightAuto(code).value
    },
    gfm: true, // 允许 Git Hub标准的markdown.
    pedantic: false, // 不纠正原始模型任何的不良行为和错误(默认为false)
    sanitize: false, // 对输出进行过滤(清理),将忽略任何已经输入的html代码(标签)
    tables: true, // 允许支持表格语法(该选项要求 gfm 为true)
    breaks: false, // 允许回车换行(该选项要求 gfm 为true)
    smartLists: true, // 使用比原生markdown更时髦的列表
    smartypants: false, // 使用更为时髦的标点
  })

  // 添加代码块按钮
  const addCodeBlockButtons = (outputIndex) => {
    const outputDiv = document.getElementById(`outPut${outputIndex}`)
    if (!outputDiv) return

    const preBlocks = outputDiv.querySelectorAll('pre:not(.processed)')
    console.log("preBlocks:", preBlocks)
    preBlocks.forEach((preBlock, i) => {
      const blockIndex = `${outputIndex}_${i}`
      preBlock.id = `output_pre_${blockIndex}`
      const div = document.createElement('div')
      div.classList.add('insert-code-btn')
      div.innerHTML = `
      <div class="inner-btns">
        <div class="copybtn-icn" id="codeCopy_${blockIndex}" title="复制">
          <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.9998 6V3C6.9998 2.44772 7.44752 2 7.9998 2H19.9998C20.5521 2 20.9998 2.44772 20.9998 3V17C20.9998 17.5523 20.5521 18 19.9998 18H16.9998V20.9991C16.9998 21.5519 16.5499 22 15.993 22H4.00666C3.45059 22 3 21.5554 3 20.9991L3.0026 7.00087C3.0027 6.44811 3.45264 6 4.00942 6H6.9998ZM5.00242 8L5.00019 20H14.9998V8H5.00242ZM8.9998 6H16.9998V16H18.9998V4H8.9998V6Z"/></svg>
        </div>
        <div class="copybtn-icn" id="codeInsert_${blockIndex}" title="插入">
          <svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM20 12L16.4645 15.5355L15.0503 14.1213L17.1716 12L15.0503 9.87868L16.4645 8.46447L20 12ZM6.82843 12L8.94975 14.1213L7.53553 15.5355L4 12L7.53553 8.46447L8.94975 9.87868L6.82843 12ZM11.2443 17H9.11597L12.7557 7H14.884L11.2443 17Z"/></svg>
        </div>
        <div class="copybtn-icn" id="codeReplace_${blockIndex}" title="替换">
          <svg viewBox="0 0 1024 1024" fill="currentColor"><path d="M480.036571 464.018286v-384a16.018286 16.018286 0 0 0-16.091428-16.018286h-384a16.018286 16.018286 0 0 0-15.945143 16.018286v384c0 8.777143 7.168 15.945143 16.018286 15.945143h384c8.777143 0 15.945143-7.094857 15.945143-15.945143zM399.945143 144.018286v256h-256v-256h256zM512 156.013714v71.972572h220.013714V384h-44.032a8.045714 8.045714 0 0 0-6.363428 12.8l80.018285 106.642286a8.045714 8.045714 0 0 0 12.8 0l79.945143-106.642286a8.045714 8.045714 0 0 0-6.363428-12.8h-44.032V208.018286a51.931429 51.931429 0 0 0-51.931429-52.004572H512zM175.981714 640a8.045714 8.045714 0 0 1-6.363428-12.8l79.945143-106.642286a8.045714 8.045714 0 0 1 12.8 0l80.018285 106.642286a8.045714 8.045714 0 0 1-6.363428 12.8h-44.032v156.013714H512v71.972572H272.018286a51.931429 51.931429 0 0 1-52.004572-51.931429V640h-44.032z m784.018286 303.981714v-384a16.018286 16.018286 0 0 0-16.018286-15.945143h-384a16.018286 16.018286 0 0 0-15.945143 15.945143v384c0 8.850286 7.094857 16.018286 15.945143 16.018286h384c8.850286 0 16.018286-7.168 16.018286-16.018286z m-80.018286-320v256h-256v-256h256z"></path></svg>
        </div>
      </div>`
      preBlock.prepend(div)
      preBlock.classList.add('processed')
    })
  }

  // 代码块按钮事件
  function codeBlockButtonEvent(outputIndex) {
    const outputDiv = document.getElementById(`outPut${outputIndex}`)
    if (!outputDiv) return

    const preBlocks = outputDiv.querySelectorAll('pre')

    preBlocks.forEach((preBlock, i) => {
      const blockIndex = `${outputIndex}_${i}`
      // 复制代码
      const codeCopyBtn = document.getElementById(`codeCopy_${blockIndex}`)

      if (codeCopyBtn && !codeCopyBtn.dataset.listenerAdded) {
        codeCopyBtn.addEventListener('click', e => {
          e.stopPropagation()
          const codeElement = preBlock.querySelector('code')
          const code = codeElement?.textContent || ''
          props.onCopy ? props.onCopy({
            message: code,
            id: props.id,
            chatId: props.chatId
          }) : null;
        })
        codeCopyBtn.dataset.listenerAdded = 'true'
      }

      // 插入代码
      const codeInsertBtn = document.getElementById(`codeInsert_${blockIndex}`)

      if (codeInsertBtn && !codeInsertBtn.dataset.listenerAdded) {
        codeInsertBtn.addEventListener('click', e => {
          e.stopPropagation()
          const codeElement = preBlock.querySelector('code')
          const code = codeElement?.textContent || ''
          props.onCodeInsert ? props.onCodeInsert(code) : null;
        })
        codeInsertBtn.dataset.listenerAdded = 'true'
      }

      // 替换代码
      const codeReplaceBtn = document.getElementById(`codeReplace_${blockIndex}`)

      if (codeReplaceBtn && !codeReplaceBtn.dataset.listenerAdded) {
        codeReplaceBtn.addEventListener('click', e => {
          e.stopPropagation()
          const codeElement = preBlock.querySelector('code')
          const code = codeElement?.textContent || ''
          props.onCodeReplace ? props.onCodeReplace(code) : null;
        })
        codeReplaceBtn.dataset.listenerAdded = 'true'
      }
    })
  }

  /**
   * 根据不同语言,设置不同的对应的代码提示
   * */
  useEffect(() => {
    let contentMock = "根据您的需求,我们需要创建一个服务编排配置,该配置首先查询用户表的信息,然后将查询结果导出到Excel文件。以下是详细的JSON配置:\n\n```json\n{\n  \"title\": \"查询用户表信息并导出到Excel\",\n  \"nodeType\": \"START\",\n  \"children\": [\n    {\n      \"title\": \"查询用户表信息\",\n      \"nodeType\": \"DATASOURCE\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"1\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"SQLForm\",\n      \"dsKey\": \"yourDataSourceKey\",  # 替换为实际的数据源ID\n      \"sql\": \"SELECT id, username, email FROM users;\",  # 替换为实际的SQL查询语句\n      \"targetName\": \"output.users\",\n      \"shareType\": 0,\n      \"justArray\": true\n    },\n    {\n      \"title\": \"JS代码 - 数据格式转换\",\n      \"nodeType\": \"JS\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"2\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"JSCodeForm\",\n      \"source\": \"// 将查询结果转换为适合导出Excel的格式\\nlet users = state.output.users;\\nlet excelData = [['ID', 'Username', 'Email']];\\nusers.forEach(user => {\\n    excelData.push([user.id, user.username, user.email]);\\n});\\nstate.output.excelData = excelData;\\nreturn state;\"\n    },\n    {\n      \"title\": \"导出Excel\",\n      \"nodeType\": \"EE\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"3\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"DeriveExcelForm\",\n      \"multipleSheets\": false,\n      \"exportData\": \"{output.excelData}\",\n      \"targetName\": \"output.filePath\"\n    },\n    {\n      \"title\": \"JS代码 - 输出文件地址\",\n      \"nodeType\": \"JS\",\n      \"children\": [],\n      \"expression\": \"\",\n      \"id\": \"4\",\n      \"parentNodeType\": \"START\",\n      \"setVerify\": true,\n      \"formName\": \"JSCodeForm\",\n      \"source\": \"// 返回文件路径\\nstate.output.response = {\\n    filePath: state.output.filePath,\\n    status: 'Success'\\n};\\nreturn state;\"\n    }\n  ],\n  \"expression\": null,\n  \"id\": \"0\",\n  \"parentNodeType\": null,\n  \"setVerify\": null,\n  \"formName\": null\n}\n```\n\n### 配置说明\n\n1. **查询用户表信息**:\n   - 使用`DATASOURCE`节点执行SQL查询,查询用户表的所有记录。\n\n2. **JS代码 - 数据格式转换**:\n   - 使用`JS`节点将查询结果转换为适合导出Excel的格式。这里我们将查询结果转换成了一个二维数组的形式,其中第一行为表头,后续行为数据行。\n\n3. **导出Excel**:\n   - 使用`EE`节点将转换好的数据导出为Excel文件,并将文件路径存储在`output.filePath`中。\n\n4. **JS代码 - 输出文件地址**:\n   - 最后一步再次使用`JS`节点,将文件路径和其他相关信息构造成最终的响应对象,方便查看和使用。\n\n请注意替换`yourDataSourceKey`为您实际的数据源ID,并调整SQL查询语句以适应您数据库的实际表结构。"
    // 此处测试用contentMock,实际用content
    setParseHtml(marked.parse(contentMock));

    setTimeout(() => {
      // 在这里执行依赖于最新DOM的操作
      addCodeBlockButtons(props.id) // 添加代码块操作按钮
      codeBlockButtonEvent(props.id) // 添加代码块按钮事件
      setupMermaidActions() // 添加自定义组件按钮事件
    }, 100);

  }, [content])


  return (
    <>
      <div className="ai-markdown">
        <div className="markdown-view" dangerouslySetInnerHTML={{ __html: parseHtml }} id={'outPut' + props.id}></div>
      </div >
    </>
  );
};
export default AIDialogue;

TypeScript入门(七)高级类型:解锁TypeScript的"终极形态"

2025年7月1日 17:28

第7章 高级类型:解锁TypeScript的"终极形态"

想象你正在探索一座神秘的类型魔法学院——高级类型(Advanced Types) 就是这里最强大的魔法咒语。如果说基础类型是编程世界的砖瓦,那么高级类型就是建筑大师的蓝图工具。这一章,我们将学会如何运用这些类型魔法,让TypeScript从优秀走向卓越!

7.1 类型别名——给复杂类型起个"好记的名字"

类型别名(Type Aliases)就像给你的类型定制一张专属名片——复杂类型从此有了简洁易记的称呼。

🏷️ 基础类型别名:简化复杂类型

// 1. 基础类型别名 - 联合类型的简化
type UserID = string | number;
type Coordinate = [number, number];

// 使用示例
const userId1: UserID = "U1001";
const userId2: UserID = 12345;
const position: Coordinate = [100, 200];

console.log(userId1); // "U1001"
console.log(userId2); // 12345
console.log(position); // [100, 200]

// 2. 对象类型别名 - 复杂结构的命名
type UserProfile = {
    id: UserID;
    name: string;
    email?: string;
    createdAt: Date;
};

// 使用示例
const user: UserProfile = {
    id: "U1001",
    name: "技术宅",
    email: "tech@example.com",
    createdAt: new Date()
};

console.log(user.name); // "技术宅"
console.log(user.id); // "U1001"
console.log(typeof user.createdAt); // "object"

// 3. 函数类型别名 - 函数签名的命名
type StringProcessor = (input: string) => string;
type NumberValidator = (value: number) => boolean;

// 实现函数
const toUpper: StringProcessor = (str) => {
    const result = str.toUpperCase();
    console.log(`转换:"${str}" -> "${result}"`);
    return result;
};

const isPositive: NumberValidator = (num) => {
    const result = num > 0;
    console.log(`验证 ${num} > 0: ${result}`);
    return result;
};

// 使用示例
console.log(toUpper("hello")); 
// "转换:"hello" -> "HELLO""
// 返回:"HELLO"

console.log(isPositive(-5)); 
// "验证 -5 > 0: false"
// 返回:false

console.log(isPositive(10)); 
// "验证 10 > 0: true"
// 返回:true

🔄 类型别名的特性与注意事项

// 别名不是新建类型 - 只是引用
type ID = UserID;
const newUserId: ID = 1002; // 完全合法
console.log(typeof newUserId); // "number"

// 递归类型别名
type TreeNode = {
    value: string;
    children?: TreeNode[];
};

const tree: TreeNode = {
    value: "根节点",
    children: [
        { value: "子节点1" },
        { 
            value: "子节点2", 
            children: [
                { value: "孙节点" }
            ]
        }
    ]
};

console.log(tree.value); // "根节点"
console.log(tree.children?.[0].value); // "子节点1"
console.log(tree.children?.[1].children?.[0].value); // "孙节点"

// 泛型类型别名
type ApiResponse<T> = {
    success: boolean;
    data: T;
    message: string;       
};

const userResponse: ApiResponse<UserProfile> = {
    success: true,
    data: user,
    message: "获取用户成功"
};

console.log(userResponse.success); // true
console.log(userResponse.data.name); // "技术宅"
console.log(userResponse.message); // "获取用户成功"

🚨 重要提醒:类型别名不会创建新类型,它只是现有类型的引用(就像给文件创建快捷方式)。使用typeof检查时,仍然是原始类型。

🎯 类型别名的应用场景

  • 简化复杂联合类型:让代码更易读
  • 统一接口定义:保持项目类型一致性
  • 函数签名复用:避免重复定义
  • 递归数据结构:树形、链表等结构

7.2 字符串字面量类型——精确到字符的"选择题"

字符串字面量类型(String Literal Types)把字符串从"任意文本"变成"选择题选项",让你的代码更加精确和安全。

🎯 基础字面量类型:限定选项范围

// 1. 状态管理 - 精确的状态定义
type LightStatus = "on" | "off" | "dimmed";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Theme = "light" | "dark" | "auto";

// 灯光控制函数
function setLight(status: LightStatus) {
    console.log(`灯光状态设置为:${status}`);
    // 模拟硬件控制
    switch(status) {
        case "on":
            console.log("💡 灯光已开启");
            break;
        case "off":
            console.log("🌙 灯光已关闭");
            break;
        case "dimmed":
            console.log("🔅 灯光已调暗");
            break;
    }
}

// 使用示例
setLight("on");    
// "灯光状态设置为:on"
// "💡 灯光已开启"

setLight("dimmed"); 
// "灯光状态设置为:dimmed"
// "🔅 灯光已调暗"

// 错误示例:不在选项中
// setLight("flash"); // 错误!"flash"不在LightStatus类型中

// 2. HTTP请求处理
function makeRequest(method: HttpMethod, url: string) {
    console.log(`发送 ${method} 请求到:${url}`);
    // 模拟网络请求
    return { method, url, timestamp: Date.now() };
}

const getResult = makeRequest("GET", "/api/users");
console.log(getResult); 
// { method: "GET", url: "/api/users", timestamp: 1704067200000 }

const postResult = makeRequest("POST", "/api/users");
console.log(postResult.method); // "POST"

🏗️ 实战应用:Redux Action系统

// Redux Action类型定义
type ActionType = "ADD_TODO" | "REMOVE_TODO" | "EDIT_TODO" | "TOGGLE_TODO";

interface BaseAction {
    type: ActionType;
    timestamp: number;
}

interface AddTodoAction extends BaseAction {
    type: "ADD_TODO";
    payload: {
        id: string;
        text: string;
        completed: boolean;
    };
}

interface RemoveTodoAction extends BaseAction {
    type: "REMOVE_TODO";
    payload: {
        id: string;
    };
}

type TodoAction = AddTodoAction | RemoveTodoAction;

// Action创建器
function createAddTodoAction(text: string): AddTodoAction {
    const action = {
        type: "ADD_TODO" as const,
        payload: {
            id: `todo_${Date.now()}`,
            text,
            completed: false
        },
        timestamp: Date.now()
    };
    console.log(`创建添加Todo Action:`, action);
    return action;
}

function createRemoveTodoAction(id: string): RemoveTodoAction {
    const action = {
        type: "REMOVE_TODO" as const,
        payload: { id },
        timestamp: Date.now()
    };
    console.log(`创建删除Todo Action:`, action);
    return action;
}

// 使用示例
const addAction = createAddTodoAction("学习TypeScript高级类型");
// "创建添加Todo Action:" { type: "ADD_TODO", payload: { id: "todo_1704067200000", text: "学习TypeScript高级类型", completed: false }, timestamp: 1704067200000 }

const removeAction = createRemoveTodoAction("todo_123");
// "创建删除Todo Action:" { type: "REMOVE_TODO", payload: { id: "todo_123" }, timestamp: 1704067200000 }

console.log(addAction.type); // "ADD_TODO"
console.log(addAction.payload.text); // "学习TypeScript高级类型"

🎨 模板字面量类型(TypeScript 4.1+)

// 模板字面量类型 - 动态生成字符串类型
type EventName<T extends string> = `on${Capitalize<T>}`;
type ButtonEvent = EventName<"click" | "hover" | "focus">; // "onClick" | "onHover" | "onFocus"

// CSS属性生成
type CSSProperty = "margin" | "padding";
type CSSDirection = "top" | "right" | "bottom" | "left";
type CSSPropertyWithDirection = `${CSSProperty}-${CSSDirection}`;
// "margin-top" | "margin-right" | "margin-bottom" | "margin-left" | "padding-top" | "padding-right" | "padding-bottom" | "padding-left"

// 使用示例
function setCSSProperty(property: CSSPropertyWithDirection, value: string) {
    console.log(`设置CSS属性:${property} = ${value}`);
    return { [property]: value };
}

const marginTop = setCSSProperty("margin-top", "10px");
// "设置CSS属性:margin-top = 10px"
console.log(marginTop); // { "margin-top": "10px" }

const paddingLeft = setCSSProperty("padding-left", "20px");
// "设置CSS属性:padding-left = 20px"
console.log(paddingLeft); // { "padding-left": "20px" }

💡 智能提示加成:在VSCode中,当你输入字面量类型时,编辑器会自动提示所有可选值,大大提升开发效率!

7.3 元组类型——数组的"精确版本"

元组(Tuples)是数组的升级版——它精确规定了数组的长度和每个位置的类型,就像给数组穿上了"定制西装"。

📏 基础元组:固定长度和类型

// 1. 基础元组定义
type PersonData = [string, number]; // [姓名, 年龄]
type Coordinate = [number, number]; // [x, y]
type RGB = [number, number, number]; // [红, 绿, 蓝]

// 使用示例
const userData: PersonData = ["张三", 30];
const position: Coordinate = [100, 200];
const redColor: RGB = [255, 0, 0];

console.log(userData[0]); // "张三"
console.log(userData[1]); // 30
console.log(position); // [100, 200]
console.log(`RGB颜色:rgb(${redColor.join(", ")})`); // "RGB颜色:rgb(255, 0, 0)"

// 错误示例:类型或长度不匹配
// const wrongData: PersonData = ["李四", "三十"]; // 错误!第二个元素应为数字
// const wrongData2: PersonData = ["王五"]; // 错误!缺少第二个元素

// 2. 解构赋值的类型安全
const [name, age] = userData;
console.log(`姓名:${name},年龄:${age}`); // "姓名:张三,年龄:30"
console.log(typeof name); // "string"
console.log(typeof age); // "number"

const [x, y] = position;
console.log(`坐标:(${x}, ${y})`); // "坐标:(100, 200)"

const [r, g, b] = redColor;
console.log(`红色分量:${r}`); // "红色分量:255"

🔧 可选元素与剩余元素

// 1. 带可选元素的元组
type RGBA = [number, number, number, number?]; // 最后的alpha通道可选

const white: RGBA = [255, 255, 255];
const semiTransparent: RGBA = [0, 0, 0, 0.5];

console.log(white); // [255, 255, 255]
console.log(semiTransparent); // [0, 0, 0, 0.5]
console.log(white.length); // 3
console.log(semiTransparent.length); // 4

// 颜色处理函数
function formatColor(color: RGBA): string {
    const [r, g, b, a] = color;
    if (a !== undefined) {
        const result = `rgba(${r}, ${g}, ${b}, ${a})`;
        console.log(`格式化RGBA颜色:`, result);
        return result;
    } else {
        const result = `rgb(${r}, ${g}, ${b})`;
        console.log(`格式化RGB颜色:`, result);
        return result;
    }
}

console.log(formatColor(white)); 
// "格式化RGB颜色:" "rgb(255, 255, 255)"
// 返回:"rgb(255, 255, 255)"

console.log(formatColor(semiTransparent)); 
// "格式化RGBA颜色:" "rgba(0, 0, 0, 0.5)"
// 返回:"rgba(0, 0, 0, 0.5)"

// 2. 剩余元素元组 (TypeScript 4.0+)
type Student = [string, ...number[]]; // 姓名 + 任意数量的分数
type DatabaseRow = [number, string, ...any[]]; // ID + 名称 + 其他字段

const tom: Student = ["Tom", 95, 88, 92, 87];
const lucy: Student = ["Lucy", 100];
const bob: Student = ["Bob", 85, 90, 88, 92, 94, 89];

console.log(tom[0]); // "Tom"
console.log(tom.slice(1)); // [95, 88, 92, 87]

// 计算平均分函数
function calculateAverage(student: Student): number {
    const [name, ...scores] = student;
    const average = scores.reduce((sum, score) => sum + score, 0) / scores.length;
    console.log(`${name}的平均分:${average.toFixed(2)}`);
    return average;
}

console.log(calculateAverage(tom)); 
// "Tom的平均分:90.50"
// 返回:90.5

console.log(calculateAverage(lucy)); 
// "Lucy的平均分:100.00"
// 返回:100

🎯 实战应用:React Hooks模拟

// React useState Hook的类型定义
type StateHook<T> = [T, (newValue: T) => void];
type EffectHook = [() => void, any[]];

// 模拟useState实现
function useState<T>(initialValue: T): StateHook<T> {
    let value = initialValue;
    
    const setValue = (newValue: T) => {
        const oldValue = value;
        value = newValue;
        console.log(`状态更新:${oldValue} -> ${newValue}`);
    };
    
    const getter = () => {
        console.log(`获取当前状态:${value}`);
        return value;
    };
    
    return [getter() as T, setValue];
}

// 使用示例
const [count, setCount] = useState(0);
// "获取当前状态:0"

console.log(count); // 0

setCount(5);
// "状态更新:0 -> 5"

const [name, setName] = useState("Alice");
// "获取当前状态:Alice"

console.log(name); // "Alice"

setName("Bob");
// "状态更新:Alice -> Bob"

// 数据库查询结果元组
type QueryResult = [boolean, any[], string?]; // [成功状态, 数据, 错误信息]

function mockQuery(sql: string): QueryResult {
    console.log(`执行SQL:${sql}`);
    
    if (sql.includes("SELECT")) {
        const mockData = [{ id: 1, name: "用户1" }, { id: 2, name: "用户2" }];
        console.log(`查询成功,返回 ${mockData.length} 条记录`);
        return [true, mockData];
    } else {
        console.log(`查询失败:不支持的SQL语句`);
        return [false, [], "不支持的SQL语句"];
    }
}

const [success, data, error] = mockQuery("SELECT * FROM users");
// "执行SQL:SELECT * FROM users"
// "查询成功,返回 2 条记录"

console.log(success); // true
console.log(data.length); // 2
console.log(error); // undefined

const [success2, data2, error2] = mockQuery("DROP TABLE users");
// "执行SQL:DROP TABLE users"
// "查询失败:不支持的SQL语句"

console.log(success2); // false
console.log(data2.length); // 0
console.log(error2); // "不支持的SQL语句"

7.4 枚举类型——常量的"豪华套装"

枚举(Enums)是为数值常量提供语义化名称的最佳工具,就像给一堆数字穿上了"有意义的外衣"。

🔢 数字枚举:自动递增的常量

// 1. 基础数字枚举
enum Direction {
    Up = 1,    // 指定起始值
    Down,      // 自动递增为2
    Left,      // 自动递增为3
    Right      // 自动递增为4
}

// 使用示例
function move(direction: Direction) {
    console.log(`移动方向:${Direction[direction]} (值: ${direction})`);
    
    switch(direction) {
        case Direction.Up:
            console.log("⬆️ 向上移动");
            break;
        case Direction.Down:
            console.log("⬇️ 向下移动");
            break;
        case Direction.Left:
            console.log("⬅️ 向左移动");
            break;
        case Direction.Right:
            console.log("➡️ 向右移动");
            break;
    }
}

move(Direction.Up);
// "移动方向:Up (值: 1)"
// "⬆️ 向上移动"

move(Direction.Right);
// "移动方向:Right (值: 4)"
// "➡️ 向右移动"

// 反向映射
console.log(Direction[1]); // "Up"
console.log(Direction[4]); // "Right"
console.log(Direction.Up); // 1
console.log(Direction.Right); // 4

// 2. 状态码枚举
enum HttpStatus {
    OK = 200,
    NotFound = 404,
    InternalServerError = 500
}

function handleResponse(status: HttpStatus) {
    console.log(`处理HTTP状态码:${status}`);
    
    if (status === HttpStatus.OK) {
        console.log("✅ 请求成功");
    } else if (status === HttpStatus.NotFound) {
        console.log("❌ 资源未找到");
    } else if (status === HttpStatus.InternalServerError) {
        console.log("💥 服务器内部错误");
    }
}

handleResponse(HttpStatus.OK);
// "处理HTTP状态码:200"
// "✅ 请求成功"

handleResponse(HttpStatus.NotFound);
// "处理HTTP状态码:404"
// "❌ 资源未找到"

📝 字符串枚举:语义化的常量

// 1. 媒体类型枚举
enum MediaType {
    JSON = "application/json",
    XML = "application/xml",
    TEXT = "text/plain",
    HTML = "text/html"
}

// HTTP请求处理
function setContentType(type: MediaType) {
    console.log(`设置Content-Type: ${type}`);
    // 模拟设置HTTP头部
    return {
        "Content-Type": type,
        "X-Timestamp": new Date().toISOString()
    };
}

const jsonHeaders = setContentType(MediaType.JSON);
// "设置Content-Type: application/json"
console.log(jsonHeaders);
// { "Content-Type": "application/json", "X-Timestamp": "2024-01-01T12:00:00.000Z" }

const xmlHeaders = setContentType(MediaType.XML);
// "设置Content-Type: application/xml"
console.log(xmlHeaders["Content-Type"]); // "application/xml"

// 2. 日志级别枚举
enum LogLevel {
    ERROR = "error",
    WARN = "warn",
    INFO = "info",
    DEBUG = "debug"
}

function log(level: LogLevel, message: string) {
    const timestamp = new Date().toISOString();
    const logEntry = `[${timestamp}] ${level.toUpperCase()}: ${message}`;
    console.log(logEntry);
    
    // 根据级别选择不同的输出方式
    switch(level) {
        case LogLevel.ERROR:
            console.error("🔴 错误日志");
            break;
        case LogLevel.WARN:
            console.warn("🟡 警告日志");
            break;
        case LogLevel.INFO:
            console.info("🔵 信息日志");
            break;
        case LogLevel.DEBUG:
            console.debug("⚪ 调试日志");
            break;
    }
}

log(LogLevel.INFO, "系统启动成功");
// "[2024-01-01T12:00:00.000Z] INFO: 系统启动成功"
// "🔵 信息日志"

log(LogLevel.ERROR, "数据库连接失败");
// "[2024-01-01T12:00:00.000Z] ERROR: 数据库连接失败"
// "🔴 错误日志"

⚡ 常量枚举:编译时优化

// 常量枚举(编译时完全删除)
const enum Color {
    Red,
    Green,
    Blue
}

// 使用常量枚举
function paintPixel(color: Color) {
    console.log(`绘制像素,颜色值:${color}`);
    
    // 编译后这里会直接使用数字值
    if (color === Color.Red) {
        console.log("🔴 绘制红色像素");
    } else if (color === Color.Green) {
        console.log("🟢 绘制绿色像素");
    } else if (color === Color.Blue) {
        console.log("🔵 绘制蓝色像素");
    }
}

paintPixel(Color.Red);
// "绘制像素,颜色值:0"
// "🔴 绘制红色像素"

paintPixel(Color.Blue);
// "绘制像素,颜色值:2"
// "🔵 绘制蓝色像素"

// 编译后的JavaScript代码中,Color.Red会被替换为0,Color.Blue会被替换为2
console.log(Color.Red); // 编译后:console.log(0);

🆚 现代替代方案:联合类型 vs 枚举

// 方案1:传统枚举
enum TraditionalLogLevel {
    Error,
    Warn,
    Info,
    Debug
}

// 方案2:现代联合类型
const ModernLogLevel = {
    Error: 0,
    Warn: 1,
    Info: 2,
    Debug: 3
} as const;

type ModernLogLevel = typeof ModernLogLevel[keyof typeof ModernLogLevel]; // 0 | 1 | 2 | 3

// 比较两种方案
function compareApproaches() {
    // 传统枚举使用
    const traditionalLevel = TraditionalLogLevel.Error;
    console.log(`传统枚举值:${traditionalLevel}`); // "传统枚举值:0"
    console.log(`传统枚举名称:${TraditionalLogLevel[traditionalLevel]}`); // "传统枚举名称:Error"
    
    // 现代联合类型使用
    const modernLevel = ModernLogLevel.Error;
    console.log(`现代联合类型值:${modernLevel}`); // "现代联合类型值:0"
    
    // 运行时大小比较
    console.log(`传统枚举在运行时存在:${typeof TraditionalLogLevel}`); // "传统枚举在运行时存在:object"
    console.log(`现代联合类型在运行时存在:${typeof ModernLogLevel}`); // "现代联合类型在运行时存在:object"
}

compareApproaches();

⚖️ 选择建议

  • 使用枚举:需要反向映射、与外部API交互、团队习惯枚举
  • 使用联合类型:追求最小运行时开销、现代TypeScript项目、函数式编程风格

7.5 类型保护与类型守卫——类型世界的"安检门"

类型保护(Type Guards)是TypeScript的类型收窄机制,让你在特定代码块内获得更精确的类型信息,就像给代码装上了"智能识别系统"。

🔍 内置类型守卫:基础类型检查

// 1. typeof 守卫 - 处理基本类型
function processValue(value: string | number | boolean) {
    console.log(`处理值:${value},类型:${typeof value}`);
    
    if (typeof value === "string") {
        // 在这个块中,value被收窄为string类型
        const result = value.toUpperCase();
        console.log(`字符串处理结果:${result}`);
        return result;
    } else if (typeof value === "number") {
        // 在这个块中,value被收窄为number类型
        const result = value.toFixed(2);
        console.log(`数字处理结果:${result}`);
        return result;
    } else {
        // 在这个块中,value被收窄为boolean类型
        const result = value ? "真" : "假";
        console.log(`布尔值处理结果:${result}`);
        return result;
    }
}

console.log(processValue("hello"));
// "处理值:hello,类型:string"
// "字符串处理结果:HELLO"
// 返回:"HELLO"

console.log(processValue(3.14159));
// "处理值:3.14159,类型:number"
// "数字处理结果:3.14"
// 返回:"3.14"

console.log(processValue(true));
// "处理值:true,类型:boolean"
// "布尔值处理结果:真"
// 返回:"真"

// 2. instanceof 守卫 - 处理类实例
class Bird {
    fly() {
        console.log("🐦 鸟儿在飞翔");
    }
    
    makeSound() {
        console.log("🎵 鸟儿在歌唱");
    }
}

class Fish {
    swim() {
        console.log("🐟 鱼儿在游泳");
    }
    
    makeSound() {
        console.log("🫧 鱼儿在吐泡泡");
    }
}

class Dog {
    run() {
        console.log("🐕 狗狗在奔跑");
    }
    
    makeSound() {
        console.log("🐕 汪汪汪!");
    }
}

function handleAnimal(animal: Bird | Fish | Dog) {
    console.log(`处理动物:${animal.constructor.name}`);
    
    // 所有动物都有makeSound方法
    animal.makeSound();
    
    if (animal instanceof Bird) {
        // 在这个块中,animal被收窄为Bird类型
        animal.fly();
    } else if (animal instanceof Fish) {
        // 在这个块中,animal被收窄为Fish类型
        animal.swim();
    } else {
        // 在这个块中,animal被收窄为Dog类型
        animal.run();
    }
}

const bird = new Bird();
const fish = new Fish();
const dog = new Dog();

handleAnimal(bird);
// "处理动物:Bird"
// "🎵 鸟儿在歌唱"
// "🐦 鸟儿在飞翔"

handleAnimal(fish);
// "处理动物:Fish"
// "🫧 鱼儿在吐泡泡"
// "🐟 鱼儿在游泳"

handleAnimal(dog);
// "处理动物:Dog"
// "🐕 汪汪汪!"
// "🐕 狗狗在奔跑"

🔑 属性检查守卫:in 操作符

// 接口定义
interface Circle {
    kind: "circle";
    radius: number;
}

interface Square {
    kind: "square";
    sideLength: number;
}

interface Triangle {
    kind: "triangle";
    base: number;
    height: number;
}

type Shape = Circle | Square | Triangle;

// 使用in守卫检查属性存在性
function calculateArea(shape: Shape): number {
    console.log(`计算图形面积,类型:${shape.kind}`);
    
    if ("radius" in shape) {
        // 在这个块中,shape被收窄为Circle类型
        const area = Math.PI * shape.radius ** 2;
        console.log(`圆形面积:π × ${shape.radius}² = ${area.toFixed(2)}`);
        return area;
    } else if ("sideLength" in shape) {
        // 在这个块中,shape被收窄为Square类型
        const area = shape.sideLength ** 2;
        console.log(`正方形面积:${shape.sideLength}² = ${area}`);
        return area;
    } else {
        // 在这个块中,shape被收窄为Triangle类型
        const area = (shape.base * shape.height) / 2;
        console.log(`三角形面积:(${shape.base} × ${shape.height}) ÷ 2 = ${area}`);
        return area;
    }
}

// 测试不同图形
const circle: Circle = { kind: "circle", radius: 5 };
const square: Square = { kind: "square", sideLength: 4 };
const triangle: Triangle = { kind: "triangle", base: 6, height: 8 };

console.log(calculateArea(circle));
// "计算图形面积,类型:circle"
// "圆形面积:π × 5² = 78.54"
// 返回:78.53981633974483

console.log(calculateArea(square));
// "计算图形面积,类型:square"
// "正方形面积:4² = 16"
// 返回:16

console.log(calculateArea(triangle));
// "计算图形面积,类型:triangle"
// "三角形面积:(6 × 8) ÷ 2 = 24"
// 返回:24

🛡️ 自定义类型守卫:类型谓词

// 自定义类型守卫函数
function isCircle(shape: Shape): shape is Circle {
    const result = shape.kind === "circle";
    console.log(`检查是否为圆形:${result}`);
    return result;
}

function isSquare(shape: Shape): shape is Square {
    const result = shape.kind === "square";
    console.log(`检查是否为正方形:${result}`);
    return result;
}

function isTriangle(shape: Shape): shape is Triangle {
    const result = shape.kind === "triangle";
    console.log(`检查是否为三角形:${result}`);
    return result;
}

// 使用自定义类型守卫
function describeShape(shape: Shape) {
    console.log(`描述图形:${shape.kind}`);
    
    if (isCircle(shape)) {
        console.log(`这是一个半径为 ${shape.radius} 的圆形`);
        console.log(`周长:${(2 * Math.PI * shape.radius).toFixed(2)}`);
    } else if (isSquare(shape)) {
        console.log(`这是一个边长为 ${shape.sideLength} 的正方形`);
        console.log(`周长:${4 * shape.sideLength}`);
    } else if (isTriangle(shape)) {
        console.log(`这是一个底边 ${shape.base},高 ${shape.height} 的三角形`);
        // 计算斜边(假设是直角三角形)
        const hypotenuse = Math.sqrt(shape.base ** 2 + shape.height ** 2);
        console.log(`斜边长度:${hypotenuse.toFixed(2)}`);
    }
}

describeShape(circle);
// "描述图形:circle"
// "检查是否为圆形:true"
// "这是一个半径为 5 的圆形"
// "周长:31.42"

describeShape(square);
// "描述图形:square"
// "检查是否为圆形:false"
// "检查是否为正方形:true"
// "这是一个边长为 4 的正方形"
// "周长:16"

// 复杂的类型守卫:检查对象结构
interface User {
    id: number;
    name: string;
    email: string;
}

interface Admin extends User {
    permissions: string[];
    lastLogin: Date;
}

function isAdmin(user: User | Admin): user is Admin {
    const hasPermissions = 'permissions' in user;
    const hasLastLogin = 'lastLogin' in user;
    const result = hasPermissions && hasLastLogin;
    console.log(`检查是否为管理员:${result}`);
    return result;
}

function handleUser(user: User | Admin) {
    console.log(`处理用户:${user.name} (ID: ${user.id})`);
    
    if (isAdmin(user)) {
        console.log(`管理员权限:${user.permissions.join(", ")}`);
        console.log(`最后登录:${user.lastLogin.toISOString()}`);
    } else {
        console.log(`普通用户,邮箱:${user.email}`);
    }
}

const regularUser: User = {
    id: 1,
    name: "张三",
    email: "zhangsan@example.com"
};

const adminUser: Admin = {
    id: 2,
    name: "李四",
    email: "lisi@example.com",
    permissions: ["read", "write", "delete"],
    lastLogin: new Date()
};

handleUser(regularUser);
// "处理用户:张三 (ID: 1)"
// "检查是否为管理员:false"
// "普通用户,邮箱:zhangsan@example.com"

handleUser(adminUser);
// "处理用户:李四 (ID: 2)"
// "检查是否为管理员:true"
// "管理员权限:read, write, delete"
// "最后登录:2024-01-01T12:00:00.000Z"

7.6 映射类型——类型转换的"流水线工厂"

映射类型(Mapped Types)让你能像操作数据一样批量转换类型,就像拥有了一条专门生产类型的"智能流水线"。

🏭 内置映射类型:TypeScript的"标准工具"

// 基础用户接口
interface User {
    id: number;
    name: string;
    email: string;
    age: number;
    isActive: boolean;
}

// 1. Partial<T> - 将所有属性变为可选
type PartialUser = Partial<User>;
/* 等价于:
{
    id?: number;
    name?: string;
    email?: string;
    age?: number;
    isActive?: boolean;
}
*/

// 使用Partial类型
function updateUser(id: number, updates: PartialUser) {
    console.log(`更新用户 ${id},更新字段:`, Object.keys(updates));
    
    // 模拟数据库更新
    const updatedFields: string[] = [];
    if (updates.name !== undefined) {
        console.log(`更新姓名:${updates.name}`);
        updatedFields.push("name");
    }
    if (updates.email !== undefined) {
        console.log(`更新邮箱:${updates.email}`);
        updatedFields.push("email");
    }
    if (updates.age !== undefined) {
        console.log(`更新年龄:${updates.age}`);
        updatedFields.push("age");
    }
    if (updates.isActive !== undefined) {
        console.log(`更新状态:${updates.isActive ? "激活" : "禁用"}`);
        updatedFields.push("isActive");
    }
    
    return { success: true, updatedFields };
}

const result1 = updateUser(1, { name: "新姓名", age: 25 });
// "更新用户 1,更新字段:" ["name", "age"]
// "更新姓名:新姓名"
// "更新年龄:25"
console.log(result1); // { success: true, updatedFields: ["name", "age"] }

const result2 = updateUser(2, { isActive: false });
// "更新用户 2,更新字段:" ["isActive"]
// "更新状态:禁用"
console.log(result2); // { success: true, updatedFields: ["isActive"] }

// 2. Required<T> - 将所有属性变为必需
interface OptionalConfig {
    host?: string;
    port?: number;
    ssl?: boolean;
    timeout?: number;
}

type RequiredConfig = Required<OptionalConfig>;
/* 等价于:
{
    host: string;
    port: number;
    ssl: boolean;
    timeout: number;
}
*/

function createConnection(config: RequiredConfig) {
    console.log(`创建连接:${config.ssl ? 'https' : 'http'}://${config.host}:${config.port}`);
    console.log(`超时设置:${config.timeout}ms`);
    
    return {
        url: `${config.ssl ? 'https' : 'http'}://${config.host}:${config.port}`,
        timeout: config.timeout,
        connected: true
    };
}

const connection = createConnection({
    host: "api.example.com",
    port: 443,
    ssl: true,
    timeout: 5000
});
// "创建连接:https://api.example.com:443"
// "超时设置:5000ms"
console.log(connection.connected); // true

// 3. Readonly<T> - 将所有属性变为只读
type ReadonlyUser = Readonly<User>;

function createReadonlyUser(userData: User): ReadonlyUser {
    console.log(`创建只读用户:${userData.name}`);
    const readonlyUser = Object.freeze({ ...userData });
    console.log(`用户已设为只读模式`);
    return readonlyUser;
}

const originalUser: User = {
    id: 1,
    name: "张三",
    email: "zhangsan@example.com",
    age: 30,
    isActive: true
};

const readonlyUser = createReadonlyUser(originalUser);
// "创建只读用户:张三"
// "用户已设为只读模式"

console.log(readonlyUser.name); // "张三"
// readonlyUser.name = "李四"; // 错误!无法修改只读属性

🎨 自定义映射类型:打造专属工具

// 1. 生成Getter方法类型
type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
/* 等价于:
{
    getId: () => number;
    getName: () => string;
    getEmail: () => string;
    getAge: () => number;
    getIsActive: () => boolean;
}
*/

// 实现Getter类
class UserWithGetters implements UserGetters {
    constructor(private user: User) {
        console.log(`创建带Getter的用户:${user.name}`);
    }
    
    getId(): number {
        console.log(`获取ID:${this.user.id}`);
        return this.user.id;
    }
    
    getName(): string {
        console.log(`获取姓名:${this.user.name}`);
        return this.user.name;
    }
    
    getEmail(): string {
        console.log(`获取邮箱:${this.user.email}`);
        return this.user.email;
    }
    
    getAge(): number {
        console.log(`获取年龄:${this.user.age}`);
        return this.user.age;
    }
    
    getIsActive(): boolean {
        console.log(`获取状态:${this.user.isActive ? "激活" : "禁用"}`);
        return this.user.isActive;
    }
}

const userWithGetters = new UserWithGetters(originalUser);
// "创建带Getter的用户:张三"

console.log(userWithGetters.getName()); 
// "获取姓名:张三"
// 返回:"张三"

console.log(userWithGetters.getAge()); 
// "获取年龄:30"
// 返回:30

// 2. 过滤特定类型的属性
type StringProperties<T> = {
    [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type UserStringProps = StringProperties<User>;
/* 等价于:
{
    name: string;
    email: string;
}
*/

function extractStringProperties(user: User): UserStringProps {
    console.log(`提取用户的字符串属性`);
    const stringProps = {
        name: user.name,
        email: user.email
    };
    console.log(`提取的属性:`, stringProps);
    return stringProps;
}

const stringProps = extractStringProperties(originalUser);
// "提取用户的字符串属性"
// "提取的属性:" { name: "张三", email: "zhangsan@example.com" }
console.log(stringProps.name); // "张三"
console.log(stringProps.email); // "zhangsan@example.com"

// 3. 创建可空版本的类型
type Nullable<T> = {
    [K in keyof T]: T[K] | null;
};

type NullableUser = Nullable<User>;
/* 等价于:
{
    id: number | null;
    name: string | null;
    email: string | null;
    age: number | null;
    isActive: boolean | null;
}
*/

function createNullableUser(partial: Partial<User>): NullableUser {
    console.log(`创建可空用户,输入字段:`, Object.keys(partial));
    
    const nullableUser: NullableUser = {
        id: partial.id ?? null,
        name: partial.name ?? null,
        email: partial.email ?? null,
        age: partial.age ?? null,
        isActive: partial.isActive ?? null
    };
    
    console.log(`创建的可空用户:`, nullableUser);
    return nullableUser;
}

const nullableUser = createNullableUser({ name: "测试用户", age: 25 });
// "创建可空用户,输入字段:" ["name", "age"]
// "创建的可空用户:" { id: null, name: "测试用户", email: null, age: 25, isActive: null }

console.log(nullableUser.name); // "测试用户"
console.log(nullableUser.id); // null

🔧 高级映射类型:复杂转换

// 1. 深度只读类型
type DeepReadonly<T> = {
    readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

interface NestedUser {
    id: number;
    profile: {
        name: string;
        contact: {
            email: string;
            phone: string;
        };
    };
    preferences: {
        theme: string;
        language: string;
    };
}

type DeepReadonlyUser = DeepReadonly<NestedUser>;

function createDeepReadonlyUser(user: NestedUser): DeepReadonlyUser {
    console.log(`创建深度只读用户:${user.profile.name}`);
    
    // 深度冻结对象
    function deepFreeze<T>(obj: T): T {
        Object.getOwnPropertyNames(obj).forEach(prop => {
            const value = (obj as any)[prop];
            if (value && typeof value === 'object') {
                deepFreeze(value);
            }
        });
        return Object.freeze(obj);
    }
    
    const frozenUser = deepFreeze({ ...user });
    console.log(`用户已深度冻结`);
    return frozenUser as DeepReadonlyUser;
}

const nestedUser: NestedUser = {
    id: 1,
    profile: {
        name: "深度用户",
        contact: {
            email: "deep@example.com",
            phone: "123-456-7890"
        }
    },
    preferences: {
        theme: "dark",
        language: "zh-CN"
    }
};

const deepReadonlyUser = createDeepReadonlyUser(nestedUser);
// "创建深度只读用户:深度用户"
// "用户已深度冻结"

console.log(deepReadonlyUser.profile.name); // "深度用户"
console.log(deepReadonlyUser.profile.contact.email); // "deep@example.com"
// deepReadonlyUser.profile.name = "新名字"; // 错误!深度只读
// deepReadonlyUser.profile.contact.email = "new@example.com"; // 错误!深度只读

// 2. 条件映射类型
type ApiEndpoints<T> = {
    [K in keyof T as T[K] extends Function ? `api${Capitalize<string & K>}` : never]: T[K];
};

interface UserService {
    create: (user: User) => Promise<User>;
    update: (id: number, user: Partial<User>) => Promise<User>;
    delete: (id: number) => Promise<void>;
    name: string; // 这个不是函数,会被过滤掉
    version: number; // 这个也不是函数,会被过滤掉
}

type UserApiEndpoints = ApiEndpoints<UserService>;
/* 等价于:
{
    apiCreate: (user: User) => Promise<User>;
    apiUpdate: (id: number, user: Partial<User>) => Promise<User>;
    apiDelete: (id: number) => Promise<void>;
}
*/

class UserApi implements UserApiEndpoints {
    apiCreate(user: User): Promise<User> {
        console.log(`API: 创建用户 ${user.name}`);
        return Promise.resolve({ ...user, id: Date.now() });
    }
    
    apiUpdate(id: number, user: Partial<User>): Promise<User> {
        console.log(`API: 更新用户 ${id},字段:`, Object.keys(user));
        return Promise.resolve({ id, ...user } as User);
    }
    
    apiDelete(id: number): Promise<void> {
        console.log(`API: 删除用户 ${id}`);
        return Promise.resolve();
    }
}

const userApi = new UserApi();

// 异步测试
async function testUserApi() {
    const newUser = await userApi.apiCreate({
        id: 0, // 会被覆盖
        name: "API用户",
        email: "api@example.com",
        age: 28,
        isActive: true
    });
    // "API: 创建用户 API用户"
    console.log(`创建的用户ID:${newUser.id}`);
    
    await userApi.apiUpdate(newUser.id, { age: 29 });
    // "API: 更新用户 1704067200000,字段:" ["age"]
    
    await userApi.apiDelete(newUser.id);
    // "API: 删除用户 1704067200000"
}

testUserApi();

7.7 条件类型——类型系统的"智能决策器"

条件类型(Conditional Types)让类型也能拥有逻辑判断能力,就像给类型系统装上了"人工智能"。

🧠 基础条件类型:类型的三目运算符

// 基础语法:T extends U ? X : Y
type IsString<T> = T extends string ? true : false;
type IsNumber<T> = T extends number ? true : false;
type IsArray<T> = T extends any[] ? true : false;

// 测试类型判断
type Test1 = IsString<"hello">; // true
type Test2 = IsString<42>;      // false
type Test3 = IsNumber<100>;     // true
type Test4 = IsArray<string[]>; // true

// 实际使用示例
function processData<T>(data: T): T extends string ? string : T extends number ? number : unknown {
    console.log(`处理数据:${data},类型:${typeof data}`);
    
    if (typeof data === "string") {
        const result = data.toUpperCase();
        console.log(`字符串处理结果:${result}`);
        return result as any;
    } else if (typeof data === "number") {
        const result = data * 2;
        console.log(`数字处理结果:${result}`);
        return result as any;
    } else {
        console.log(`未知类型,原样返回`);
        return data as any;
    }
}

const stringResult = processData("hello");
// "处理数据:hello,类型:string"
// "字符串处理结果:HELLO"
console.log(stringResult); // "HELLO"

const numberResult = processData(42);
// "处理数据:42,类型:number"
// "数字处理结果:84"
console.log(numberResult); // 84

const unknownResult = processData(true);
// "处理数据:true,类型:boolean"
// "未知类型,原样返回"
console.log(unknownResult); // true

🔄 分布式条件类型:批量处理联合类型

// 分布式条件类型 - 自动分发到联合类型的每个成员
type ToArray<T> = T extends any ? T[] : never;

// 当T是联合类型时,条件类型会分布到每个成员
type StringOrNumberArray = ToArray<string | number>; // string[] | number[]
type BooleanArray = ToArray<boolean>; // boolean[]

// 实际应用:过滤联合类型
type NonNullable<T> = T extends null | undefined ? never : T;

type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string

// 使用示例
function filterNullable<T>(value: T): NonNullable<T> | null {
    if (value === null || value === undefined) {
        console.log(`过滤掉空值:${value}`);
        return null;
    }
    console.log(`保留有效值:${value}`);
    return value as NonNullable<T>;
}

const result1 = filterNullable("hello");
// "保留有效值:hello"
console.log(result1); // "hello"

const result2 = filterNullable(null);
// "过滤掉空值:null"
console.log(result2); // null

const result3 = filterNullable(undefined);
// "过滤掉空值:undefined"
console.log(result3); // null

🔍 类型推断:infer 关键字的魔法

// 1. 提取函数返回类型
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

type StringFunction = () => string;
type NumberFunction = (x: number) => number;
type VoidFunction = () => void;

type StringReturn = ReturnType<StringFunction>; // string
type NumberReturn = ReturnType<NumberFunction>; // number
type VoidReturn = ReturnType<VoidFunction>; // void

// 实际使用
function createTypedFunction<F extends (...args: any[]) => any>(
    fn: F
): (...args: Parameters<F>) => ReturnType<F> {
    return (...args) => {
        console.log(`调用函数,参数:`, args);
        const result = fn(...args);
        console.log(`函数返回:`, result);
        return result;
    };
}

const add = (a: number, b: number) => a + b;
const typedAdd = createTypedFunction(add);

const sum = typedAdd(5, 3);
// "调用函数,参数:" [5, 3]
// "函数返回:" 8
console.log(sum); // 8

// 2. 提取数组元素类型
type ArrayElement<T> = T extends (infer U)[] ? U : never;

type StringArrayElement = ArrayElement<string[]>; // string
type NumberArrayElement = ArrayElement<number[]>; // number
type MixedArrayElement = ArrayElement<(string | number)[]>; // string | number

// 实际应用:数组处理函数
function processArray<T extends any[]>(
    arr: T,
    processor: (item: ArrayElement<T>) => ArrayElement<T>
): T {
    console.log(`处理数组,长度:${arr.length}`);
    const result = arr.map(item => {
        const processed = processor(item);
        console.log(`处理项目:${item} -> ${processed}`);
        return processed;
    }) as T;
    console.log(`处理完成`);
    return result;
}

const numbers = [1, 2, 3, 4, 5];
const doubled = processArray(numbers, x => x * 2);
// "处理数组,长度:5"
// "处理项目:1 -> 2"
// "处理项目:2 -> 4"
// "处理项目:3 -> 6"
// "处理项目:4 -> 8"
// "处理项目:5 -> 10"
// "处理完成"
console.log(doubled); // [2, 4, 6, 8, 10]

const strings = ["hello", "world", "typescript"];
const uppercased = processArray(strings, s => s.toUpperCase());
// "处理数组,长度:3"
// "处理项目:hello -> HELLO"
// "处理项目:world -> WORLD"
// "处理项目:typescript -> TYPESCRIPT"
// "处理完成"
console.log(uppercased); // ["HELLO", "WORLD", "TYPESCRIPT"]

// 3. 提取Promise的值类型
type Awaited<T> = T extends Promise<infer U> ? U : T;

type StringPromise = Promise<string>;
type NumberPromise = Promise<number>;
type NestedPromise = Promise<Promise<boolean>>;

type StringValue = Awaited<StringPromise>; // string
type NumberValue = Awaited<NumberPromise>; // number
type BooleanValue = Awaited<NestedPromise>; // Promise<boolean> (只解包一层)

// 深度解包Promise
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;

type DeepBooleanValue = DeepAwaited<NestedPromise>; // boolean

// 实际应用:异步函数包装器
async function wrapAsync<T extends Promise<any>>(
    promise: T
): Promise<{ success: boolean; data: Awaited<T> | null; error: string | null }> {
    console.log(`包装异步操作`);
    try {
        const data = await promise;
        console.log(`异步操作成功:`, data);
        return { success: true, data, error: null };
    } catch (error) {
        console.log(`异步操作失败:`, error);
        return { success: false, data: null, error: String(error) };
    }
}

// 测试异步包装器
async function testAsyncWrapper() {
    const successPromise = Promise.resolve("成功数据");
    const failPromise = Promise.reject("失败原因");
    
    const result1 = await wrapAsync(successPromise);
    // "包装异步操作"
    // "异步操作成功:" "成功数据"
    console.log(result1); // { success: true, data: "成功数据", error: null }
    
    const result2 = await wrapAsync(failPromise);
    // "包装异步操作"
    // "异步操作失败:" "失败原因"
    console.log(result2); // { success: false, data: null, error: "失败原因" }
}

testAsyncWrapper();

🎯 实战应用:智能表单验证系统

// 字段类型定义
type FieldType = "text" | "number" | "email" | "date" | "boolean";

// 基础字段配置
interface BaseFieldConfig {
    name: string;
    type: FieldType;
    required?: boolean;
    label?: string;
}

// 根据字段类型确定值类型的条件类型
type FieldValue<T extends BaseFieldConfig> = 
    T extends { type: "number" } ? number :
    T extends { type: "date" } ? Date :
    T extends { type: "boolean" } ? boolean :
    string; // 默认为string (text, email)

// 根据required属性确定是否可选
type FieldValueWithRequired<T extends BaseFieldConfig> = 
    T extends { required: true } 
        ? FieldValue<T> 
        : FieldValue<T> | undefined;

// 表单值类型生成器
type FormValues<Fields extends readonly BaseFieldConfig[]> = {
    [K in Fields[number]["name"]]: FieldValueWithRequired<
        Extract<Fields[number], { name: K }>
    >;
};

// 验证规则类型
type ValidationRule<T> = {
    validate: (value: T) => boolean;
    message: string;
};

type FieldValidation<T extends BaseFieldConfig> = {
    [K in T["name"]]: ValidationRule<FieldValueWithRequired<T>>[];
};

// 表单配置示例
const userFormConfig = [
    { name: "username", type: "text", required: true, label: "用户名" },
    { name: "email", type: "email", required: true, label: "邮箱" },
    { name: "age", type: "number", required: false, label: "年龄" },
    { name: "birthdate", type: "date", required: false, label: "生日" },
    { name: "isActive", type: "boolean", required: true, label: "是否激活" }
] as const;

type UserFormValues = FormValues<typeof userFormConfig>;
/* 等价于:
{
    username: string;
    email: string;
    age: number | undefined;
    birthdate: Date | undefined;
    isActive: boolean;
}
*/

// 验证器实现
class FormValidator<T extends readonly BaseFieldConfig[]> {
    constructor(private config: T) {
        console.log(`创建表单验证器,字段数量:${config.length}`);
    }
    
    validate(values: FormValues<T>): { isValid: boolean; errors: string[] } {
        console.log(`开始验证表单`);
        const errors: string[] = [];
        
        for (const field of this.config) {
            const value = (values as any)[field.name];
            console.log(`验证字段 ${field.name}${value}`);
            
            // 检查必填字段
            if (field.required && (value === undefined || value === null || value === "")) {
                const error = `${field.label || field.name} 是必填字段`;
                console.log(`验证失败:${error}`);
                errors.push(error);
                continue;
            }
            
            // 类型特定验证
            if (value !== undefined && value !== null && value !== "") {
                switch (field.type) {
                    case "email":
                        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
                        if (!emailRegex.test(String(value))) {
                            const error = `${field.label || field.name} 格式不正确`;
                            console.log(`验证失败:${error}`);
                            errors.push(error);
                        }
                        break;
                    case "number":
                        if (typeof value !== "number" || isNaN(value)) {
                            const error = `${field.label || field.name} 必须是有效数字`;
                            console.log(`验证失败:${error}`);
                            errors.push(error);
                        }
                        break;
                    case "date":
                        if (!(value instanceof Date) || isNaN(value.getTime())) {
                            const error = `${field.label || field.name} 必须是有效日期`;
                            console.log(`验证失败:${error}`);
                            errors.push(error);
                        }
                        break;
                }
            }
            
            console.log(`字段 ${field.name} 验证通过`);
        }
        
        const isValid = errors.length === 0;
        console.log(`表单验证${isValid ? "成功" : "失败"},错误数量:${errors.length}`);
        return { isValid, errors };
    }
}

// 使用示例
const validator = new FormValidator(userFormConfig);
// "创建表单验证器,字段数量:5"

// 测试有效数据
const validData: UserFormValues = {
    username: "techuser",
    email: "tech@example.com",
    age: 25,
    birthdate: new Date("1998-01-01"),
    isActive: true
};

const result1 = validator.validate(validData);
// "开始验证表单"
// "验证字段 username:techuser"
// "字段 username 验证通过"
// "验证字段 email:tech@example.com"
// "字段 email 验证通过"
// "验证字段 age:25"
// "字段 age 验证通过"
// "验证字段 birthdate:Wed Jan 01 1998 00:00:00 GMT+0800"
// "字段 birthdate 验证通过"
// "验证字段 isActive:true"
// "字段 isActive 验证通过"
// "表单验证成功,错误数量:0"
console.log(result1); // { isValid: true, errors: [] }

// 测试无效数据
const invalidData: UserFormValues = {
    username: "", // 必填但为空
    email: "invalid-email", // 格式错误
    age: undefined, // 可选字段
    birthdate: undefined, // 可选字段
    isActive: true
};

const result2 = validator.validate(invalidData);
// "开始验证表单"
// "验证字段 username:"
// "验证失败:用户名 是必填字段"
// "验证字段 email:invalid-email"
// "验证失败:邮箱 格式不正确"
// "验证字段 age:undefined"
// "字段 age 验证通过"
// "验证字段 birthdate:undefined"
// "字段 birthdate 验证通过"
// "验证字段 isActive:true"
// "字段 isActive 验证通过"
// "表单验证失败,错误数量:2"
console.log(result2); // { isValid: false, errors: ["用户名 是必填字段", "邮箱 格式不正确"] }

🎯 高级类型最佳实践与设计模式

📋 类型设计原则对比表

原则 说明 好的例子 避免的例子
类型安全优先 优先保证类型安全,避免any type ID = string | number type ID = any
语义化命名 类型名称要有明确含义 type UserRole = "admin" | "user" type T1 = "a" | "b"
组合优于继承 使用联合类型和交叉类型 type Response<T> = Success<T> | Error 复杂的类继承链
渐进式增强 从简单类型开始,逐步增强 先定义基础类型,再扩展 一开始就定义复杂类型
可读性重要 复杂类型要有注释和示例 带有详细注释的类型定义 没有说明的复杂类型

🛠️ 实用工具类型库

// 1. 深度部分类型
type DeepPartial<T> = {
    [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// 2. 深度必需类型
type DeepRequired<T> = {
    [P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P];
};

// 3. 选择性Pick
type PickByType<T, U> = {
    [K in keyof T as T[K] extends U ? K : never]: T[K];
};

// 4. 排除性Omit
type OmitByType<T, U> = {
    [K in keyof T as T[K] extends U ? never : K]: T[K];
};

// 5. 可空类型转换
type Nullish<T> = {
    [K in keyof T]: T[K] | null | undefined;
};

// 6. 函数参数提取
type Head<T extends any[]> = T extends [infer H, ...any[]] ? H : never;
type Tail<T extends any[]> = T extends [any, ...infer R] ? R : never;

// 使用示例
interface ComplexUser {
    id: number;
    profile: {
        name: string;
        settings: {
            theme: string;
            notifications: boolean;
        };
    };
    permissions: string[];
    createdAt: Date;
}

type PartialUser = DeepPartial<ComplexUser>;
type StringFields = PickByType<ComplexUser, string>;
type NonStringFields = OmitByType<ComplexUser, string>;

console.log("工具类型演示完成"); // "工具类型演示完成"

🎨 类型体操进阶挑战

// 挑战1:字符串操作类型
type Reverse<S extends string> = S extends `${infer First}${infer Rest}` 
    ? `${Reverse<Rest>}${First}` 
    : S;

type ReversedHello = Reverse<"hello">; // "olleh"

// 挑战2:数组长度计算
type Length<T extends readonly any[]> = T['length'];
type ArrayLength = Length<[1, 2, 3, 4, 5]>; // 5

// 挑战3:对象键值转换
type Flip<T extends Record<string, string>> = {
    [K in keyof T as T[K]]: K;
};

type Original = { a: "x", b: "y", c: "z" };
type Flipped = Flip<Original>; // { x: "a", y: "b", z: "c" }

// 挑战4:递归计数器
type Counter<N extends number, C extends any[] = []> = 
    C['length'] extends N ? C : Counter<N, [...C, any]>;

type FiveItems = Counter<5>; // [any, any, any, any, any]

console.log("类型体操挑战完成"); // "类型体操挑战完成"

📚 本章核心收获总结

🎯 掌握的核心技能

  1. 类型别名 🏷️

    • 简化复杂类型定义
    • 提高代码可读性和维护性
    • 支持泛型和递归定义
  2. 字符串字面量类型 🎯

    • 精确控制字符串值范围
    • 模板字面量类型的强大功能
    • 与Redux、状态管理的完美结合
  3. 元组类型 📏

    • 固定长度和类型的数组
    • 支持可选元素和剩余元素
    • React Hooks等场景的类型安全
  4. 枚举类型 🔢

    • 数字枚举和字符串枚举
    • 常量枚举的编译时优化
    • 现代联合类型替代方案
  5. 类型保护 🛡️

    • typeof、instanceof、in守卫
    • 自定义类型谓词函数
    • 运行时类型收窄机制
  6. 映射类型 🏭

    • 内置工具类型的使用
    • 自定义映射类型的创建
    • 批量类型转换的强大能力
  7. 条件类型 🧠

    • 类型系统的逻辑判断
    • infer关键字的类型推断
    • 分布式条件类型的特性

🚀 实战应用场景

  • API接口设计:使用映射类型生成请求/响应类型
  • 状态管理:字面量类型确保Action类型安全
  • 表单验证:条件类型实现智能表单系统
  • 工具函数:类型保护确保函数参数安全
  • 组件开发:元组类型支持Hooks等模式

💡 进阶学习建议

  1. 多练习类型体操:提升类型思维能力
  2. 阅读优秀库源码:学习实际应用模式
  3. 关注TypeScript更新:掌握最新特性
  4. 结合实际项目:在真实场景中应用
  5. 分享交流经验:与团队共同成长

🎉 恭喜你! 你已经掌握了TypeScript高级类型的核心技能,这些强大的类型工具将让你的代码更加安全、优雅和高效。接下来,我们将探索模块与命名空间的世界,学习如何组织大型TypeScript项目!

手写call全解析:从原理到实现,让this指向不再迷路~

作者 十盒半价
2025年7月1日 17:28

一、JS this 之谜:call 为何是救星?

在 JavaScript 的世界里,this就像一个调皮的精灵,函数在哪调用,它就指向哪。比如:

const obj = { name: '稀土掘金' };
function sayHi() {
  console.log(`Hello, ${this.name}!`); // 直接调用时,this指向window/global
}
sayHi(); // 输出:Hello, undefined!(尴尬~)

这时候,call闪亮登场!它能强行改变this的指向,就像给this戴上 GPS 定位器:

sayHi.call(obj); // 输出:Hello, 稀土掘金!(完美~)

核心作用:让函数在指定的上下文(context)中执行,精准控制this的指向。

二、call、apply、bind 大不同:先搞懂兄弟仨的分工

在动手写call之前,先快速理清这三个高频方法的区别(避免后续混淆):

2.1 相同点:都是 this 的 “搬运工”

  • 作用:动态修改函数内部this的指向。
  • 原则:绝不修改原函数的this,只在调用时临时生效(bind除外)。

2.2 不同点:执行方式、传参、绑定性质大揭秘

特性 call apply bind
执行时机 立即执行(同步) 立即执行(同步) 返回新函数(延迟执行)
传参方式 逐个传参(arg1, arg2 数组传参([arg1, arg2] 可预传参(bind(ctx, a)(b)
this 绑定 临时绑定(仅本次调用) 临时绑定(仅本次调用) 永久绑定(返回新函数)

小剧场

  • callapply就像急性子,一说 “改 this” 就立刻执行;
  • bind则像慢性子,先记下来(返回新函数),等你喊 “执行” 才动~

三、手写 call 的核心思路:三步搞定 this 绑定

现在进入正题:如何手写一个myCall,实现和原生call一样的效果?
核心原理:把函数 “寄生” 到目标对象上,通过调用对象属性的方式执行函数,这样函数内的this就会指向该对象。

3.1 第一步:处理 context(防止迷路的保底方案)

  • 场景 1:如果用户没传context,或者传了null/undefined,默认指向window(严格模式下为undefined,但这里简化处理)。

  • 场景 2:如果传的是原始值(如string/number),需要用Object()包装成对象(这是 JS 的隐式转换规则)。

Function.prototype.myCall = function(context) {
  // 1. 处理context:默认指向window,原始值转对象
  context = context !== null && context !== undefined ? Object(context) : window;

3.2 第二步:寄生函数:用 Symbol 防止属性冲突

  • 问题:如果目标对象context本身有同名方法(比如context.fn),直接挂载函数会覆盖原有属性。

  • 解决方案:用 ES6 的Symbol生成唯一键名(Symbol 就像身份证号,绝对不会重复)。

  // 2. 生成唯一键名,避免覆盖context原有属性
  const fnKey = Symbol('临时函数');
  // 3. 将当前函数“寄生”到context上
  context[fnKey] = this; // this指向调用myCall的函数(比如sayHi)

3.3 第三步:执行函数 + 清理现场(做个有素质的 JS 开发者)

  • 传参处理:用剩余参数...args收集call的参数(第一个参数是context,从第二个开始是函数的实参)。

  • 清理现场:执行完函数后,删除context上的临时属性,避免污染对象。

  // 4. 收集参数(第一个参数是context,从第二个开始是函数的参数)
  const args = [...arguments].slice(1); // 例如:myCall(obj, a, b) → args = [a, b]
  // 5. 执行函数,并接收返回值
  const result = context[fnKey](...args);
  // 6. 清理现场:删除临时属性
  delete context[fnKey];
  // 7. 返回函数执行结果
  return result;
};

四、完整代码 + 测试:验证 myCall 是否靠谱

4.1 完整实现(带详细注释)

Function.prototype.myCall = function(context) {
  // 处理context:null/undefined→window,原始值→对象(如:'稀土'→new String('稀土'))
  context = context !== null && context !== undefined ? Object(context) : window;
  // 生成唯一键名,避免与context原有属性冲突
  const fnKey = Symbol('myCall-temp');
  // 将当前函数(this)挂载到context上
  context[fnKey] = this;
  // 收集参数:排除第一个参数(context),剩余的作为函数参数
  const args = [...arguments].slice(1);
  // 执行函数并获取结果
  const result = context[fnKey](...args);
  // 删除临时属性,保持context纯净
  delete context[fnKey];
  // 返回函数执行结果
  return result;
};

4.2 测试案例:验证不同场景

案例 1:普通对象绑定

const obj = { name: '稀土掘金' };
function sayHi(hello) {
  return `${hello}, ${this.name}!`;
}
console.log(sayHi.myCall(obj, 'Hi')); // 输出:Hi, 稀土掘金!(正确~)

案例 2:context 为 null/undefined

function sayHello() {
  return `Hello, ${this.defaultName}`;
}
const globalObj = { defaultName: 'Global' };
// 当context为null/undefined时,myCall默认指向window(这里用globalObj模拟window)
console.log(sayHello.myCall(null)); // 输出:Hello, Global!(正确~)

案例 3:原始值作为 context

function logType() {
  console.log(this instanceof String); // true(包装成String对象)
  console.log(`类型:${this}`); // 类型:稀土掘金
}
logType.myCall('稀土掘金'); // 输出:true 和 类型:稀土掘金(正确~)

五、常见坑点与优化:让 myCall 更健壮

5.1 坑点 1:忘记绑定函数类型

  • 错误场景:如果调用myCall的不是函数(比如null.myCall()),会报错。

  • 解决方案:调用前校验this是否为函数类型。

if (typeof this !== 'function') {
  throw new TypeError('myCall must be called on a function');
}

5.2 坑点 2:严格模式下的 this 处理

  • 规则:在严格模式中,callcontext如果是null/undefined,函数内的this就是null/undefined,而不是window

  • 优化:移除context = ... || window的默认处理,严格遵循规范。

// 严格模式下的处理(可选优化)
context = context === null || context === undefined ? context : Object(context);

六、扩展思考:apply 和 bind 该怎么写?(留给聪明的你)

6.1 apply 的实现(与 call 的唯一区别:参数是数组)

Function.prototype.myApply = function(context, args) {
  context = context !== null && context !== undefined ? Object(context) : window;
  const fnKey = Symbol('myApply-temp');
  context[fnKey] = this;
  // apply的参数是数组,直接展开即可
  const result = context[fnKey](...(args || []));
  delete context[fnKey];
  return result;
};

6.2 bind 的实现(返回新函数,永久绑定 this)

Function.prototype.myBind = function(context) {
  const fn = this;
  const args = [...arguments].slice(1);
  // 返回新函数,支持new调用(通过原型链继承)
  return function NewFn() {
    // 如果是new调用,this指向实例对象,否则指向context
    const ctx = this instanceof NewFn ? this : Object(context);
    return fn.apply(ctx, args.concat([...arguments]));
  };
};

七、总结:手写 call 的灵魂三问

  1. 为什么要用 Symbol?
    防止临时属性与目标对象的原有属性冲突,就像给临时变量起了个独一无二的名字。

  2. call 和 apply 的本质区别是什么?
    只是参数形式不同,call传散落的参数,apply传数组,底层原理完全一致。

  3. bind 为什么返回新函数?
    因为它需要 “记住” 绑定的this和参数,等后续调用时再执行,就像一个 “延迟执行的函数包裹器”。

通过手写call,我们不仅深入理解了 JS 的this机制,还掌握了函数 “寄生”、属性隔离、参数处理等核心技巧。下次遇到this指向问题,再也不用慌啦~ 😉

CSS伪元素实战指南:让你的页面交互更优雅

2025年7月1日 17:15

前言

在前端开发中,CSS伪元素是一个强大而优雅的工具,它能让我们在不增加HTML标签的情况下实现丰富的视觉效果。本文将通过实际案例,带你深入理解伪元素的核心概念和实战应用。

什么是CSS伪元素?

CSS伪元素(Pseudo-elements)是一种特殊的选择器,它允许我们选择元素的特定部分并为其添加样式。最常用的伪元素包括 ::before::after,它们分别在元素内容的开始之前和结束之后插入内容。

伪元素的核心特点:

  • 不需要在HTML中声明额外的标签
  • 可以像真实的DOM元素一样参与文档流
  • 完全依赖CSS实现,具有良好的可维护性
  • content 属性是必需的,通常设置为空字符串

实战案例一:打造优雅的悬停下划线效果

让我们从一个实际的导航链接效果开始。传统做法可能需要额外的span标签来实现下划线动画,但使用伪元素可以让代码更加简洁:

<div class="container"> <h1>这是一个标题</h1>
<p>这是一个段落 这是一个段落 这是一个段落 这是一个段落</p> 
<a href="#" class="more">查看更多</a> </div>
.container .more {
  display: inline-block;
  background-color: #007bff;
  color: #fff;
  text-decoration: none;
  position: relative;
  transition: all 0.3s ease;
}

.container .more::before {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 2px;
  background-color: #f00;
  transform: scaleX(0);
  transform-origin: bottom right;
  transition: transform 0.3s ease;
}

.container .more:hover::before {
  transform: scaleX(1);
  transform-origin: bottom left;
}

这个效果的精妙之处在于:

  1. 初始状态:下划线通过 scaleX(0) 完全隐藏
  2. 变换原点transform-origin: bottom right 确保动画从右侧开始
  3. 悬停效果:改变原点为左侧,创造出从右到左的流畅动画

image.png

鼠标悬停--->

image.png

实战案例二:创建纯CSS箭头图标

在很多UI场景中,我们需要向右的箭头来表示"查看更多"或"进入下一步"。使用伪元素可以轻松实现:

.box {
  height: 100px;
  background-color: rgb(192, 238, 112);
  padding: 0 10px;
  position: relative;
}

.box::before {
  content: "";
  position: absolute;
  width: 10px;
  height: 10px;
  right: 10px;
  top: 45px;
  border: 1px solid #000;
  border-left-color: transparent;
  border-bottom-color: transparent;
  transform: rotate(45deg);
}

技术解析:

  • 创建一个10x10像素的正方形
  • 通过设置左边框和下边框为透明,只保留右边框和上边框
  • 45度旋转后形成完美的向右箭头
  • 使用绝对定位精确控制箭头位置

image.png

Stylus:让CSS更像编程

Stylus是一个CSS预处理器,它让CSS编写变得更加简洁和模块化。看看同样的效果用Stylus如何实现:

.container
  text-align center
  min-width 600px
  margin 0 auto
  padding 20px
  font-family Arial, sans-serif
  
  h1
    text-align center
  
  p
    line-height 1.6
  
  .more 
    display inline-block
    background-color #007bff
    color white
    text-decoration none
    position relative
    transition all .3s ease

    &::before
      content ""
      position absolute
      left 0
      bottom 0
      width 100%
      height 2px
      background-color red
      transform scaleX(0)
      transform-origin bottom right
      transition transform .3s ease
      
    &:hover::before
      transform scaleX(1)
      transform-origin bottom left

Stylus的优势:

  • 省略分号和大括号,代码更简洁
  • 支持嵌套语法,结构更清晰
  • 使用 & 符号引用父选择器
  • 支持变量、函数等编程特性

伪元素的最佳实践

1. 语义化考虑

伪元素适合纯装饰性内容,如图标、装饰线条等。避免用于承载重要信息的内容。

2. 性能优化

/* 推荐:使用transform进行动画 */
.element::before {
  transform: scaleX(0);
  transition: transform 0.3s ease;
}

/* 避免:频繁改变layout属性 */
.element::before {
  width: 0;
  transition: width 0.3s ease;
}

3. 浏览器兼容性

现代浏览器对 ::before::after 支持良好,但注意双冒号语法(推荐)vs单冒号语法(旧版本)的区别。

实际应用场景

  1. 导航装饰:悬停效果、下划线动画
  2. 图标系统:箭头、关闭按钮、装饰图形
  3. 内容增强:引号、序号、分隔符
  4. 布局辅助:清除浮动、创建几何形状

总结

CSS伪元素是前端开发者工具箱中的瑞士军刀,它能在保持HTML结构简洁的同时实现丰富的视觉效果。结合Stylus等预处理器,我们可以写出更加优雅和可维护的样式代码。

关键要点回顾:

  • 伪元素不会增加DOM节点,性能友好
  • content 属性是使用伪元素的必要条件
  • 适合实现装饰性和交互性效果
  • 结合CSS动画可以创造出色的用户体验

掌握伪元素的使用技巧,将让你的前端开发技能更上一层楼。在下一个项目中,不妨尝试用伪元素替换一些不必要的HTML标签,体验代码简洁带来的快感!

🧱 优雅封装 Axios 请求:从错误处理到统一响应

作者 梦语花
2025年7月1日 17:10

在前端开发中,我们经常需要与后端 API 进行交互。为了提高代码的可维护性、减少重复逻辑,并提升开发效率,对 axios 的请求进行合理的封装是非常有必要的。

本文将带你一步步了解如何通过一个通用的 to 函数,优雅地封装 axios 请求,实现:

  • 统一处理业务异常;
  • 简化异步调用流程;
  • 避免层层嵌套的 if (res.code !== 0) 判断;
  • 提高代码可读性和复用性。

🧩 背景问题:传统写法的痛点

假设你有一个接口调用如下:

js
深色版本
let res = await getMerchantOrder(data);
if (res.code != 0) {
  showToast(res.msg);
  return false;
}

这种写法虽然功能正常,但存在几个明显的问题:

  1. 每个请求都需要手动判断 res.code
  2. 错误提示分散,不易统一管理
  3. 返回值结构不一致,不利于后续处理
  4. 难以集中处理网络层和业务层的错误

这会导致你的业务代码中充斥大量“防御性判断”,严重影响可读性。


✨ 解决方案:使用 to 函数统一处理请求结果

我们可以创建一个名为 to 的辅助函数,用于包装任何基于 Promise 的请求(如 axios),并返回一个标准格式的 [error, data] 结构。

🔨 实现代码如下:

ts
深色版本
/**
 * 将 Promise 包装成 [error, data] 形式,简化异步操作错误处理
 * @param {Promise} promise - 需要包装的 Promise 对象
 * @param {*} errorExt - 可选,附加的错误信息对象
 * @returns {Array} [error, data]
 */
export const to = async (promise, errorExt) => {
  try {
    const res = await promise;

    // 如果业务状态码不是成功状态(例如 code !== 0)
    if (res?.code !== 0) {
      const errorMessage = res?.msg ?? '获取数据失败';
      showToast(errorMessage);
      return [new Error(errorMessage), null];
    }

    // 成功时返回 null 错误 + 数据
    return [null, res.data];

  } catch (err) {
    // 捕获异常并返回 [error, null]
    let parsedError = err;

    if (errorExt) {
      parsedError = Object.assign({}, err, errorExt);
    }

    return [parsedError, null];
  }
};

💡 注意:这里的 showToast 是你项目中已有的 UI 提示方法,比如 uni.showToastElMessage 或自定义 Toast 工具。


📦 使用方式:简洁又直观

现在你可以这样使用:

ts
深色版本
const [err, data] = await to(getMerchantOrder(data));

if (err) {
  console.error('请求失败:', err.message);
  return;
}

// 正常处理 data
console.log('订单数据:', data);

✅ 优势总结:

特性 描述
✅ 统一错误处理 所有错误都在 err 中返回
✅ 清晰结构 返回 [err, data],无需 try/catch 嵌套
✅ 减少冗余 不再需要 if (res.code !== 0) 处理
✅ 可扩展性强 支持自定义错误信息注入

🧪 示例场景对比

❌ 原始写法:

js
深色版本
async function fetchOrder() {
  try {
    const res = await getMerchantOrder(data);
    if (res.code !== 0) {
      showToast(res.msg);
      return;
    }
    // do something with res.data
  } catch (err) {
    showToast('网络异常');
  }
}

✅ 使用 to 后:

js
深色版本
async function fetchOrder() {
  const [err, data] = await to(getMerchantOrder(data));

  if (err) {
    // 统一错误提示 + 日志记录
    console.error('请求出错', err.message);
    return;
  }

  // 直接处理 data
  console.log('订单数据:', data);
}

是不是更加清爽了?


🧰 高级用法:结合 TypeScript 更加安全

如果你使用的是 TypeScript,可以为 to 添加类型支持:

ts
深色版本
type ResponseData<T> = {
  code: number;
  msg?: string;
  data?: T;
};

export async function to<T>(promise: Promise<ResponseData<T>>, errorExt?: any): Promise<[Error | null, T | null]> {
  try {
    const res = await promise;

    if (res.code !== 0) {
      const msg = res.msg || '获取数据失败';
      showToast(msg);
      return [new Error(msg), null];
    }

    return [null, res.data as T];
  } catch (err) {
    const error = errorExt ? Object.assign({}, err, errorExt) : err;
    return [error, null];
  }
}

这样在 IDE 中就可以获得完整的类型提示和自动补全支持!


🧠 总结:让请求更优雅、更可控

通过对 Axios 请求的封装,我们实现了:

  • 统一的错误处理机制
  • 标准化的数据结构返回
  • 更清晰的业务代码逻辑
  • 更强的可维护性和可测试性

无论你是开发小程序、Web 应用,还是构建中后台系统,这样的封装都能极大提升开发体验和代码质量。


📚 扩展建议

如果你希望进一步增强这个封装工具,还可以考虑加入以下功能:

  • 自动重试机制(如网络失败时 retry);
  • 请求拦截器/响应拦截器;
  • 全局错误上报;
  • 接口 Mock 支持;
  • 请求缓存策略;
  • 支持取消请求(AbortController);

📌 最后提醒:
不要把所有请求都写得“千篇一律”,封装是手段,统一和规范才是目的


如需我帮你生成一个完整的 Axios 封装模块(包含拦截器、TypeScript 支持、Mock 等),欢迎继续提问 👍

你可以将这篇文章发布到:

  • 掘金 / CSDN / 博客园 / 知乎 / 微信公众号 / Notion / 内部 Wiki
  • 或者作为团队编码规范的一部分共享给同事

Turborepo 完全指南:从零到生产

2025年7月1日 15:09

简介:为什么 Monorepos 如此重要(以及为什么大多数人不知道它们) 残酷的现实是,99% 的软件工程师从未听说过 Monorepos,更不用说正确实现它了。这并非出于个人喜好,而是基于一些团队在实际工作中的经验,他们面对的是一个简单 React 应用程序的构建时间超过一小时的情况。

我亲眼目睹了糟糕的 monorepo 实现所带来的后果:20 多名工程师组成的团队每次推送到 dev 分支都要等待一个多小时才能构建完成。罪魁祸首是什么?一个包含压缩版 Ant Design 组件的 React 项目,在 CI/CD 流水线中解压,编译耗时过长。解决方案很简单——使用 Ant Design 的内置主题或创建私有 npm 包——但由于组织阻力,我们不得不将整个文件夹复制粘贴到源代码中,只为将构建时间从 60 多分钟缩短到 5 分钟。

本指南将教您如何使用 Turborepo 以正确的方式构建 monorepos,让您从完全的初学者变成熟练的实践者。

Turborepo 是什么? Turborepo 是一个专为 Monorepos 设计的高性能 JavaScript 和 TypeScript 代码库构建系统。它解决了 Monorepos 面临的根本性扩展问题:随着代码库的增长,构建时间会变得异常缓慢。

Monorepo 扩展问题 Monorepos 有很多优势——共享代码、一致的工具、跨项目的原子提交——但它们难以高效扩展。每个工作区都有各自的优势:

测试套件 Linting 规则 构建过程 依赖项 单个 Monorepo 可能需要执行数千个任务。如果没有合适的工具,这会导致速度急剧下降,影响团队构建和交付软件。

Turborepo 通过智能缓存和任务编排解决了这个问题。它的远程缓存存储了所有任务的结果,这意味着您的 CI 无需重复执行相同的工作。

先决条件和平台说明 重要提示: Turborepo 在类 Unix 系统上效果最佳。如果您使用的是 Windows 11,请考虑使用 WSL 2.0,因为您可能会遇到特定于平台的问题。文件系统命令可能因平台而异。

分步实施指南 让我们使用 Next.js 前端、Express.js API 和共享包构建一个完整的 monorepo。

最终项目结构 my-monorepo/ ├── apps/ │ ├── web/ # Next.js frontend │ └── api/ # Express backend ├── packages/ │ ├── ui/ # Shared UI components │ ├── types/ # Shared TypeScript types │ └── docs/ # Documentation (optional) ├── turbo.json ├── tsconfig.base.json ├── package.json └── .gitignore 步骤 1:Clean Slate 设置 首先,确保您有一个干净的环境:

Remove any existing Turbo installation

npm uninstall -g turbo rm -rf node_modules rm package-lock.json 步骤 2:初始化 Monorepo mkdir my-turborepo && cd my-turborepo npm init -y 编辑你的根package.json:

{ "name": "my-monorepo", "private": true, "scripts": { "dev": "turbo run dev", "build": "turbo run build", "lint": "turbo run lint", "test": "turbo run test" },
"workspaces": [ "apps/", "packages/" ], "devDependencies": { "turbo": "2.5.4" }, "packageManager": "npm@10.9.2" } 安装 Turborepo:

npm install 步骤3:配置Turborepo turbo.json在根目录中创建:

{ "$schema": "", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/", ".next/", "!.next/cache/**"] }, "dev": { "cache": false, "persistent": true }, "lint": { "dependsOn": ["^lint"] }, "test": { "dependsOn": ["^test"] }, "check-types": { "dependsOn": ["^check-types"] } } } 步骤4:创建项目结构 mkdir -p apps/web apps/api packages/ui packages/types packages/docs 步骤 5:设置 Next.js 前端 导航到 Web 应用程序目录并创建 Next.js 应用程序:

cd apps/web npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" 更新apps/web/package.json以包含共享依赖项www.mytiesarongs.com

{ "name": "web", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "check-types": "tsc --noEmit" }, "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", "next": "15.3.4", "@repo/ui": "", "types": "" }, "devDependencies": { "typescript": "^5", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.3.4" } } 步骤6:设置Express.js API 导航到 API 目录:

cd ../../apps/api npm init -y 安装依赖项:

npm install express cors npm install --save-dev typescript ts-node @types/express @types/node @types/cors nodemon 创造tsconfig.json:

{ "extends": "../../tsconfig.base.json", "compilerOptions": { "target": "ES2020", "module": "CommonJS", "moduleResolution": "Node", "outDir": "dist", "rootDir": "src", "esModuleInterop": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "skipLibCheck": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } 创造src/index.ts:

import express from 'express'; import cors from 'cors'; import type { User } from 'types';

const app = express(); const PORT = process.env.PORT || 3001;

app.use(cors()); app.use(express.json());

app.get('/', (req, res) => { res.json({ message: 'API is running successfully!' }); });

app.get('/users', (req, res) => { const users: User[] = [ { id: '1', name: 'John Doe' }, { id: '2', name: 'Jane Smith' } ]; res.json(users); });

app.listen(PORT, () => { console.log(🚀 API server running on ); }); 更新apps/api/package.json:

{ "name": "api", "version": "1.0.0", "scripts": { "dev": "nodemon src/index.ts", "build": "tsc", "start": "node dist/index.js", "check-types": "tsc --noEmit" }, "dependencies": { "express": "^4.18.2", "cors": "^2.8.5", "types": "*" }, "devDependencies": { "typescript": "^5.0.0", "ts-node": "^10.9.0", "@types/express": "^4.17.17", "@types/node": "^20.0.0", "@types/cors": "^2.8.13", "nodemon": "^3.0.0" } } 步骤 7:创建共享包 共享类型包 创造packages/types/index.ts:

export interface User { id: string; name: string; email?: string; }

export interface ApiResponse { data: T; message?: string; success: boolean; }

export interface ButtonProps { children: React.ReactNode; onClick?: () => void; variant?: 'primary' | 'secondary' | 'danger'; disabled?: boolean; } 创造packages/types/package.json:

{ "name": "types", "version": "1.0.0", "main": "index.ts", "types": "index.ts" } 共享 UI 组件包 创造packages/ui/src/Button.tsx:

import React from 'react'; import type { ButtonProps } from 'types';

export const Button: React.FC = ({ children, onClick, variant = 'primary', disabled = false }) => { const baseClasses = 'px-4 py-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';

const variantClasses = { primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500', danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500' };

const disabledClasses = 'opacity-50 cursor-not-allowed';

const className = ${baseClasses} ${variantClasses[variant]} ${disabled ? disabledClasses : ''};

return ( {children} ); }; 创造packages/ui/src/index.ts:

export { Button } from './Button'; 创造packages/ui/package.json:

{ "name": "@repo/ui", "version": "0.0.0", "private": true, "main": "./src/index.ts", "types": "./src/index.ts", "exports": { ".": "./src/index.ts" }, "scripts": { "lint": "eslint . --max-warnings 0", "check-types": "tsc --noEmit" }, "dependencies": { "react": "^19.0.0", "types": "*" }, "devDependencies": { "@types/node": "^20.0.0", "@types/react": "^19.0.0", "typescript": "^5.0.0", "eslint": "^9.0.0" } } 步骤 8:配置 TypeScript tsconfig.base.json在根目录中创建:

{ "compilerOptions": { "target": "ES2020", "lib": ["dom", "dom.iterable", "es6"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "baseUrl": ".", "paths": { "@repo/ui": ["./packages/ui/src"], "@repo/ui/": ["./packages/ui/src/"], "types": ["./packages/types"], "types/": ["./packages/types/"] } }, "include": ["/*.ts", "/*.tsx"], "exclude": ["node_modules", "dist", ".next"] } 步骤9:更新Git配置 创建/更新.gitignore:

Dependencies

node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log*

Build outputs

dist/ .next/ .vercel/

Turborepo

.turbo/

Environment variables

.env .env.local .env.development.local .env.test.local .env.production.local

IDE

.vscode/ .idea/

OS

.DS_Store Thumbs.db 步骤 10:测试你的 Monorepo 安装所有依赖项:

npm install 构建一切:

npm run build 您应该看到如下输出:

Tasks: 4 successful, 4 total Cached: 0 cached, 4 total Time: 15.2s 运行开发服务器:

npm run dev 这将启动您的 Next.js 应用程序(通常在端口 3000 上)和 Express API(在端口 3001 上)。

步骤 11:验证一切正常 通过再次运行构建来测试缓存:

npm run build 您应该看到:

Tasks: 4 successful, 4 total Cached: 4 cached, 4 total Time: 185ms >>> FULL TURBO 恭喜!您已在几毫秒内成功构建并优化了 Monorepo。

Turborepo 关键命令 turbo build- 按照依赖关系图构建所有包 turbo build --filter=web- 仅构建 Web 应用程序及其依赖项 turbo build --dry- 展示无需执行即可构建的内容 turbo dev- 启动所有开发服务器 turbo lint- 对所有包进行 linting turbo test- 对所有包运行测试 高级配置 远程缓存 对于团队,设置远程缓存以共享构建工件:

npx turbo login npx turbo link 包过滤 针对特定包:

Build only frontend

turbo build --filter=web

Build frontend and its dependencies

turbo build --filter=web...

Build everything except docs

turbo build --filter=!docs 常见问题故障排除 构建失败:检查你的turbo.json任务依赖关系 导入错误:验证您的 TypeScript 路径映射tsconfig.base.json 工作区解析:确保package.json工作区配置正确 Windows 上的平台问题:使用 WSL 2.0 或确保您拥有最新的 Node.js 版本 结论 您现在拥有一个可用于生产的 Turborepo monorepo,它具有:

✅ 使用 TypeScript 的 Next.js 前端 ✅ 带有 TypeScript 的 Express.js API ✅ 共享 UI 组件 ✅ 共享类型定义 ✅ 智能缓存和任务编排 ✅ 初始设置后即可快速构建 这个基础架构可以扩展以支持数十个应用程序和软件包,同时保持快速的构建时间和开发人员的工作效率。关键在于理解 Turborepo 不仅仅是一个构建工具,它是一个完整的开发工作流程优化系统,可以改变您的团队交付软件的方式。访问了解更多相关代码资讯:www.ysdslt.com

在 TypeScript 项目中高效使用 node_modules 中的全局类型

作者 前端微白
2025年7月1日 15:01

在 TypeScript 项目中,全局类型定义的管理和使用是提升开发效率和代码质量的关键。本文详细解析如何从 node_modules 引入全局类型到你的 src 目录中,解决常见问题并分享最佳实践。

理解全局类型与模块类型

在 TypeScript 中,类型系统主要分为两种形式:

graph LR
    A[TypeScript 类型] --> B[全局类型]
    A --> C[模块类型]
    B --> D[全局可用<br>无需导入]
    C --> E[需要显式导入<br>import type]

全局类型的特点:

  • 无需导入:在任何文件中直接可用
  • 自动合并:同名接口会自动合并
  • 环境声明:通常通过 .d.ts 文件定义

配置 tsconfig.json 正确引用全局类型

正确配置 tsconfig.json 是使用全局类型的基础:

{
  "compilerOptions": {
    "types": ["node", "lodash", "express"],
    "typeRoots": [
      "./node_modules/@types",
      "./global_types"
    ]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"],
  "exclude": ["node_modules"]
}

配置详解:

  • types:显式列出要包含的全局类型包
  • typeRoots:定义类型查找路径(默认包含 node_modules/@types)
  • include:指定需要编译的文件范围

三种引用全局类型的方法

方法1:直接通过 npm 包引用(推荐)

步骤:

  1. 安装带有全局类型声明的包:

    npm install --save-dev @types/lodash @types/express
    
  2. 在 tsconfig.json 中配置:

    {
      "compilerOptions": {
        "types": ["lodash", "express"]
      }
    }
    
  3. 在项目中直接使用全局类型:

    // src/main.ts
    const user: Express.User = { 
      id: 1, 
      name: "John" 
    };
    
    const sortedItems = _.sortBy([3, 1, 2]);
    

方法2:手动声明全局类型扩展

当需要扩展第三方库的类型或自定义全局类型时:

  1. 创建 src/global.d.ts 文件:

    // 扩展 Express 的 Request 接口
    declare namespace Express {
      interface Request {
        userId: number;
        requestTime: Date;
      }
    }
    
    // 自定义全局类型
    interface GlobalConfig {
      apiBaseUrl: string;
      version: string;
    }
    
    // 通过模块扩充声明全局变量
    declare global {
      const appConfig: GlobalConfig;
    }
    
  2. 在项目中直接使用:

    // src/routes/auth.ts
    import { Request, Response } from 'express';
    
    export const getProfile = (req: Request, res: Response) => {
      console.log(req.userId); // 扩展的属性
      console.log(appConfig.version); // 全局变量
      // ...
    };
    

方法3:通过三斜线指令引用特定位置(传统方式)

/// <reference types="jquery" />
/// <reference path="../node_modules/custom-lib/types/index.d.ts" />

现代TypeScript项目中通常不再推荐使用三斜线指令,优先使用 tsconfig.json 配置

解决常见问题与冲突

问题1:全局类型未被正确识别

解决方案步骤:

  1. 确认包已正确安装:node_modules/@types/ 下存在对应包
  2. tsconfig.jsontypes 字段中添加包名
  3. 重启TypeScript服务(VSCode中按Ctrl+Shift+P > Restart TS Server)

问题2:全局类型冲突处理

当多个模块定义相同全局类型时:

// 使用 declare module 合并而不是覆盖
declare module 'express' {
  interface Request {
     customProperty: string;
  }
}

// 使用模块重命名解决冲突
import { User as AuthUser } from '@auth/types';
import { User as DbUser } from '@db/types';

type UnifiedUser = AuthUser & DbUser;

问题3:自定义全局类型优先级

在项目中创建 types/ 目录存放自定义类型:

project-root/
├── src/
│   └── ...
├── types/
│   ├── global.d.ts
│   └── custom-types/
└── tsconfig.json

配置 tsconfig.json

{
  "compilerOptions": {
    "typeRoots": [
      "./node_modules/@types",
      "./types"
    ]
  }
}

最佳实践指南

1. 优先选择 @types 命名空间包

# 安装类型定义
npm install --save-dev @types/react @types/node

2. 模块类型 vs 全局类型使用场景

场景 推荐方式
库的类型定义 模块类型 import type { ... }
框架扩展 (Express, Vue) 全局类型声明
项目配置/全局常量 全局接口声明
跨组件共享类型 模块类型导出/导入

3. 自定义全局类型命名规范

// ✅ 推荐使用前缀避免冲突
interface MyApp_UserPreferences {
  theme: 'dark' | 'light';
  fontSize: number;
}

// ✅ 使用命名空间组织
declare namespace MyApp {
  interface Config {
    apiEndpoint: string;
    debugMode: boolean;
  }
}

// ❌ 避免泛型全局名称
interface Config {} // 可能与其他库冲突

4. 版本控制与类型同步

添加预安装脚本确保类型与依赖同步:

// package.json
{
  "scripts": {
    "preinstall": "npm list @types/react || npm install @types/react@^18"
  }
}

实战案例:Express项目中扩展全局类型

项目结构:

express-api/
├── src/
│   ├── app.ts
│   ├── middleware/
│   │   └── auth.ts
│   └── routes/
│       └── users.ts
├── types/
│   └── express.d.ts
└── tsconfig.json

扩展步骤:

  1. 创建类型扩展文件 types/express.d.ts:

    import { User } from '../src/models/user';
    
    declare global {
      namespace Express {
        interface Request {
          user?: User;
          startTime: number;
        }
      }
    }
    
  2. 配置 tsconfig.json:

    {
      "compilerOptions": {
        "typeRoots": ["./node_modules/@types", "./types"]
      }
    }
    
  3. 在中间件中使用扩展属性:

    // src/middleware/auth.ts
    import { Request, Response, NextFunction } from 'express';
    
    export const authMiddleware = (
      req: Request, 
      res: Response, 
      next: NextFunction
    ) => {
      req.startTime = Date.now();
      
      // 模拟用户验证
      req.user = {
        id: 1,
        name: 'John Doe',
        role: 'admin'
      };
      
      next();
    };
    
  4. 在路由中安全访问扩展属性:

    // src/routes/users.ts
    import { Router } from 'express';
    import { authMiddleware } from '../middleware/auth';
    
    const router = Router();
    
    router.use(authMiddleware);
    
    router.get('/profile', (req, res) => {
      if (!req.user) {
        return res.status(401).send('Unauthorized');
      }
      
      const processingTime = Date.now() - req.startTime;
      res.json({
        user: req.user,
        processingTime: `${processingTime}ms`
      });
    });
    
    export default router;
    

调试与验证类型声明

检查类型覆盖范围的命令

npx tsc --noEmit --listFiles | grep .d.ts

生成类型声明地图

// tsconfig.json
{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

小结

graph TD
    A[项目开始] --> B{需要全局类型?}
    B -->|是| C[查看官方类型库是否可用]
    B -->|否| D[使用模块类型导入]
    C --> E[安装官方类型包]
    E --> F[配置 tsconfig.json]
    F --> H[在 types 字段添加包名]
    C -->|无可用| G[创建 custom.d.ts]
    G --> I[声明全局类型]
    I --> J[在 typeRoots 添加自定义路径]
    H --> K[类型生效]
    J --> K
    K --> L[严格检查类型安全]
    L --> M[编译通过]

通过本文的指导,你可以:

  1. 正确配置项目以使用 node_modules 中的全局类型
  2. 解决类型引用中的常见问题
  3. 扩展第三方库的类型定义
  4. 安全地使用自定义全局类型
  5. 优化类型声明管理策略

遵循这些实践,你的 TypeScript 项目将具有更高的开发效率和更强的类型安全性,同时避免常见的全局类型冲突问题。

状态三国演义:Redux、Zustand、Jotai 源码趣谈

作者 Kincy
2025年7月1日 14:49

你可能用过 Redux,也可能听说过 Zustand 和 Jotai。它们风格各异,但你有没有想过,它们的“性格”其实就藏在源码里?

本文将用一种轻松趣味的方式,带你深入三大主流 React 状态库的源码核心,从 Redux 的严谨,到 Zustand 的野性,再到 Jotai 的哲学,我们一起探究状态管理的“性格密码”。

没有枯燥的源码大段堆砌,有的是清晰逻辑、趣味比喻和可视化解析。

👉 准备好了吗?来一场状态管理的三国探秘之旅吧!

状态管理江湖,群雄并起。Redux 持剑而立,Zustand 嘶吼奔腾,Jotai 端坐冥思。今日我潜入三大门派,直探源码秘笈,与你共赏这场状态三国的传奇。

一、引言:状态的烦恼

曾几何时,React 的 useState 用得正香,组件树一复杂,状态就满天飞。

  • 数据共享:prop drilling 痛苦无比
  • 组件通信:context 一层层包
  • 异步状态:还得加 middleware?

于是江湖上诞生了三大门派:

  • Redux:长者风范,纪律严明,招式繁多
  • Zustand:野性十足,灵活简单,喜欢钩子
  • Jotai:哲学派别,万物皆原子,依赖追踪

它们不仅风格各异,源码中更藏着各自的“性格密码” ,接下来我们就一探究竟!

二、Redux:老派骑士的战斗美学

Redux 是 React 状态管理领域的“武当派”。讲究纪律、数据单向流、不可变,是许多项目的启蒙之选。

🌟 核心机制回顾

  • createStore:建功立业的入口函数
  • dispatch(action):发起一次战斗
  • reducer(state, action):决定如何改变世界
  • subscribe(listener):监听战况

🔍 源码核心拆解:createStore

function createStore(reducer, preloadedState, enhancer) {
  let currentState = preloadedState
  let currentListeners = []

  function getState() {
    return currentState
  }

  function dispatch(action) {
    currentState = reducer(currentState, action)
    currentListeners.forEach(listener => listener())
    return action
  }

  function subscribe(listener) {
    currentListeners.push(listener)
    return () => {
      const index = currentListeners.indexOf(listener)
      currentListeners.splice(index, 1)
    }
  }

  // 派发一次初始化 action
  dispatch({ type: '@@redux/INIT' })

  return { getState, dispatch, subscribe }
}

🔧 重点解析

  • reducer 是“状态的唯一改造者”
  • dispatch 就是发号施令,触发 state 更新
  • subscribe 是监听器列表,纯数组,简单高效

🧠 源码的美感

Redux 核心逻辑不到 100 行,却构建出一个完整的状态系统。这种结构就像一个“微型操作系统”,你能感受到作者那种“我不搞花活,代码要讲道理”的精神。

🍄 applyMiddleware:洋葱中传秘籍

Redux 支持中间件,通过 applyMiddleware(...middlewares) 实现异步、日志等功能。来看它的实现核心:

function applyMiddleware(...middlewares) {
  return (createStore) => (reducer, preloadedState) => {
    const store = createStore(reducer, preloadedState)
    let dispatch = store.dispatch

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (action) => dispatch(action),
    }

    const chain = middlewares.map((middleware) => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)

    return { ...store, dispatch }
  }
}

关键词:compose、闭包、递归调度

🌰 举个栗子:

const logger = ({ getState }) => next => action => {
  console.log('before:', getState())
  const result = next(action)
  console.log('after:', getState())
  return result
}

是不是像一颗洋葱?一层层包裹住 dispatch,每层都可以动手脚,堪称函数式编程的艺术品!

🧝 Redux 小结:老骑士的优雅与疲惫

Redux 就像一位着甲持剑的老骑士:

  • 🏛️ 优点:稳定、可预测、开发工具强大
  • 🐌 缺点:样板多、入门曲线陡、写起来有点“重”

虽然它已经不再风靡,但它的“源码结构之美”依然值得每一位工程师学习。

三、Zustand:状态管理的灵兽馆

Zustand(德语中的“状态”)是一个由 Poimandres 团队打造的轻量状态库,特点是:

  • 不用 Provider,不用 Context
  • 核心 API 一个:create
  • 写起来像“驯一只动物”:你设定它,它忠实响应

🧠 核心机制回顾

Zustand 的使用方式大概长这样:

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}))

你在组件里只要这样用:

const count = useStore((state) => state.count)

没有 context、没有 reducer、甚至没有 action 类型 —— 是不是有点“反 Redux”的味道?

🔍 源码核心拆解:createStore

Zustand 本质是封装了一个状态容器,代码核心如下:

export const createStore = (initializer) => {
  let state
  const listeners = new Set()

  const setState = (partial, replace) => {
    const nextState = typeof partial === 'function' ? partial(state) : partial
    if (!Object.is(nextState, state)) {
      const previousState = state
      state = replace ? nextState : { ...state, ...nextState }
      listeners.forEach((listener) => listener(state, previousState))
    }
  }

  const getState = () => state

  const subscribe = (listener) => {
    listeners.add(listener)
    return () => listeners.delete(listener)
  }

  const api = { setState, getState, subscribe }
  state = initializer(setState, getState, api)

  return api
}

🧪 设计亮点

  • 使用闭包保存 state 与 listeners,不用 React context
  • 通过 subscribe 精准监听,性能比 context 更稳
  • setState 支持函数式更新,支持合并与替换

🐻 Zustand 像什么?

它像一只你亲手养大的灵兽:

  • 你教它:“当我点按钮就 count++”
  • 它记住后,每次都忠诚响应
  • 而且不吵不闹,不加 Provider,不抱怨 Props

一句话总结:

Redux 是写代码,Zustand 是养宠物。

🔍 Selector 精准监听的实现

Zustand 的 useStore(selector) 并不会导致全组件更新,因为它用了 shallow equality + 订阅机制:

useSyncExternalStore(
  store.subscribe,
  () => selector(store.getState()),
)

React 的 useSyncExternalStore 是为了这种外部状态订阅而生,Zustand 在这里用得恰到好处。

四、Jotai:状态即原子,原子即宇宙

Jotai(日语中意为“原子”)是 Recoil 的轻量替代者,由著名的 Zustand 团队出品。它提出了一个哲学式概念:

组件 = 原子状态的观察者

每个状态都是独立原子,互不干扰,天然响应式。

🧠 核心机制回顾

Jotai 的使用方式像这样:

const countAtom = atom(0)

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>
}

就像 Vue 的 ref,一切都是“声明原子 + 使用原子”。

🔍 源码核心拆解:createAtom + read/write

Jotai 的原子其实是一个描述对象(不是值本身):

const countAtom = {
  read: () => 0,
  write: (get, set, update) => ...
}

再结合 useAtom 和 Provider 构建状态依赖图。核心代码(简化后):

const atomState = new WeakMap()

function readAtom(atom) {
  const value = atom.read(getter)
  atomState.set(atom, { value })
  return value
}

function writeAtom(atom, update) {
  if (atom.write) {
    atom.write(getter, setter, update)
  }
}

Jotai 是一个运行时构建依赖关系图的库,当你读取一个 atom 时,它就追踪了谁依赖谁

🧮 Suspense & 异步 atom

Jotai 支持 Suspense,异步 atom 只需:

const userAtom = atom(async () => {
  const res = await fetch('/api/user')
  return res.json()
})

一旦调用 useAtom(userAtom),Suspense 自动生效。

这要归功于 Jotai 在内部构建了一张“Promise 状态图”,并且用异步缓存做了懒加载处理。

🧘 Jotai 像什么?

Jotai 更像一位禅宗大师:

  • 不喧哗,不绑定 UI 框架
  • 所有状态皆可组合,读写可分离
  • 当你需要异步,甚至 Suspense,它也心如止水

一句话总结:

Jotai 是 “响应式哲学” 的代言人。

五、源码哲学对比

特性 Redux Zustand Jotai
核心结构 Reducer + Store + Action createStore + Hook 原子 Atom + Provider
Context ✅ 必须 ❌ 不用 ✅ 可选(用于依赖图)
懒加载支持 ❌ 无 ✅ 内建 ✅ 优雅且兼容 Suspense
写法风格 函数式 + 模块化 Hook 风格 + 自由 哲学式声明 + 响应式
学习曲线 📈 陡峭 🟦 平稳 📉 初学友好,高级复杂
适用场景 企业级系统 中小项目 + 快速原型 响应式编程、组件粒度状态

六、结语:源码之下,皆有趣味

三大状态库,各有性格,各有光辉。

  • Redux 是架构控的浪漫
  • Zustand 是自由派的狂欢
  • Jotai 是响应式哲学的轻吟

源码不仅是工具的“内部运作”,更是设计哲学与取舍艺术的结晶

📚 阅读源码的姿势:

  • 带着问题去看
  • 从用户代码出发,逆推到内部实现
  • 找到每一个 “aha!” 的瞬间

状态管理没有银弹,但有哲学。

Redux 教我们组织和规范,Zustand 给我们灵活与自由,而 Jotai 提醒我们“原子化”思维的美妙。

如果你想写一个状态库,不妨多看看他们的源码。你会发现,每一行“简单”的代码背后,其实都藏着深思熟虑的设计决策。

💬 最后:你更喜欢哪一个状态库?或者你有没有看过哪个库的源码印象最深?欢迎留言讨论!

如果这篇文章对你有启发,一键三连 + 收藏 是我继续产出源码趣读内容的最大动力❤️

AJAX:前后端通信的桥梁

2025年7月1日 14:45

AJAX前后端连接的桥梁,每次点击按钮、提交表单,都伴随着整个页面的闪烁刷新。用户需要等待服务器返回完整的HTML页面,体验被切割成碎片化的等待过程。这种"点击-等待-刷新"的交互模式,不仅效率低下,更破坏了用户体验的连贯性。直到AJAX技术横空出世,这场静默革命彻底改写了Web交互的规则。它让浏览器能够在后台悄悄与服务器对话动态更新页面局部内容

一、AJAX工作机制解析

  • AJAX的全称为Asyncronous JavaScript And XML,即异步的JavaScript和XML,是浏览器与服务器之间进行交互的一种方式,前后端重要的机制,它允许浏览器向服务器发送请求,并接收服务器返回的数据,而无需重新加载整个页面。 AJAX的核心在于异步数据交换,其工作流程如下:
  1. 事件触发:用户交互(点击/输入等)触发JavaScript函数
  2. 创建请求:通过XMLHttpRequestfetch()创建HTTP请求
  3. 发送请求:向服务器发送请求而不阻塞页面
  4. 处理响应:接收服务器数据后更新特定DOM元素
// 现代fetch API示例
document.getElementById("btn").addEventListener("click", () => {
  fetch("http://localhost:3000/login?username=admin&password=123")
    .then(response => response.json())
    .then(data => console.log("响应数据:", data));
});

二、关键演进:从XHR到Fetch

早期AJAX依赖XMLHttpRequest对象:

const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/data");
xhr.onload = () => console.log(xhr.responseText);
xhr.send();

现代开发更倾向使用Fetch API

  • 基于Promise的链式调用
  • 更简洁的语法
  • 内置JSON解析等实用功能
  • 支持async/await异步模式

三、真实案例:登录系统实现

结合您提供的代码示例,我们实现了一个完整的登录流程:

前端实现(HTML+JavaScript)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div>
    <input type="text" placeholder="Add a task">
   
    </input>
    <input type="password" placeholder="Add a task">
   
    </input>
     <button id="btn">Add</button>
  </div>
  <script>
      let btn = document.getElementById("btn");
      btn.addEventListener("click", function () {
        console.log("clicked");
        fetch(
          "http://localhost:3000/login?username=admin&password=123",
          {
            method: "GET",
          }
        )
          .then((response) => {
            return response.json();
          })
          .then((data) => {
            console.log(data);
          });
      });
  </script>
</body>
</html>

image.png 实现界面如下:虽然只有add有用,点击add触发回调

JavaScript功能部分

  • 获取按钮元素let btn = document.getElementById("btn");

    • 通过ID选择器获取按钮DOM元素
  • 添加点击事件监听btn.addEventListener("click", function () { ... });

    • 当用户点击按钮时触发回调函数
  • 发送AJAX请求

    • 使用fetch() API发送HTTP请求

    • 请求URL:http://localhost:3000/login?username=admin&password=123

      • 目标服务器:本地3000端口
      • 请求路径:/login
      • 查询参数:username=admin 和 password=123(硬编码)
    • 请求方法:GET

  • 处理响应

    • .then((response) => { return response.json(); })

      • 将服务器响应解析为JSON格式
    • .then((data) => { console.log(data); });

      • 将解析后的数据输出到控制台 后端实现(Node.js HTTP服务器)
const http = require("http");
const port = 3000;

const server = http.createServer((req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "*");

  if (req.url.startsWith("/login")) {
    const username = req.url.split("?")[1].split("=")[1].split("&")[0];
    const password = req.url.split("&")[1].split("=")[1];

    console.log(`当前登录账号为:${username}, 密码为:${password}`);

    res.end("login success");
  }
});

server.listen(port, () => {
  console.log(`Server running at http://localhost:${port}/`);
});

image.png

%E5%B1%8F%E5%B9%95%E5%BD%95%E5%88%B6%202025-07-01%20143202_converted.gif 成功运行js代码后得到上图,再点击html代码的按钮

image.png

控制台显示:

image.png

结语

如果你也学习前端的小白,不妨来试试ajax,感受前后端联调的魅力吧

Quill 自定义模块的开发之旅

作者 Arise
2025年7月1日 14:32

在开发一个旧项目的富文本组件(Quill)的过程中在新增功能的时候用到了自定义模块的api。记录了一些遇到的问题和解决方式。

先说一下基本的代码内容

const config = {
  modules: {
    toolbar: {
      container: [
        [{ size: ['12px','14px','16px','18px','20px'], default: 14 }],
        ['bold', 'italic', 'c-underline-style', 'strike', 'link'],
        ['c-line-height'],
        [{ align: [] }],
        [{ color: [] }, { background: [] }],
        ['c-h-divider'],
        ['clean'],
      ],
      handlers: {
        underline() {
          return false;
        },
        'line-height'() {
          return false;
        },
      },
    },
  },
  theme: 'snow',
};
/**
* 一共开发3个模块
* c-underline-style 自定义文字下划线
* c-line-height 自定义行高
* c-h-divider 自定义分割线
*/ 

// 注册插件
registerPlugin(QuillClass);
// 生成实例
const editor: Quill = new QuillClass(ref, config);
// 创建自定义菜单
createMenu();
// 绑定自定义模块的matcher匹配
bindQuillMatcher(editor, Delta)

自定义的模块的注册一定要在实例创建之前,否则的话不会生效; 这部分是主体逻辑,然后我们分开的单独说每一部分;

先说 registerPlugin 自定义模块注册部分: 字体大小和字体是Quill内置的功能,只需要注册一下你要增加字体的白名单就可以。

不过关于字体需要说明一下,最好采用 'attributors/style/font' 这个行内样式的方式对字体进行赋值方式。 也尝试过使用 ‘attributors/class/font’ 的方式。这种是会产生一个类名,你需要在类名下自己设置使用的字体。如果你要用到的字体都是浏览器本身就支持的,或者你采用@face-font 的方式注册的也没有什么问题。 但是如果你用的是 Fontface 的方式注册追加的字体。用类名的方式就不太方便了。所以采用'attributors/style/font'的方式适配面更广一些。

注册

  const registerPlugin = (Quill) => {
    // 注册字体大小白名单
    const SizeStyle = Quill.import('attributors/style/size');
    SizeStyle.whitelist = ['12px','14px','16px','18px','20px'];
    Quill.register(SizeStyle, true);
    // 注册字体白名单
    const FontAttributor = Quill.import('attributors/style/font');
    FontAttributor.whitelist = ['字体1','字体2'];
    Quill.register(FontAttributor, true);

    // 注册分割线
    registerDividerBlots(Quill);
    // 注册下划线
    registerUnderlineFormats(Quill);
    // 注册行高
    registerLineHeightBlots(Quill);
  };

下划线的自定义注册

Quill 内置的 underline 仅支持单线的。他的设置值是 true(开启)和 false(关闭)

我需要对 underline 的改造:

  1. 增加下划线的颜色设置
  2. 增加下划线的样式设置
  3. 增加下划线粗细设置 (这里的粗细并不是直接设置,是属于样式中的一种:单线<标准>和单线<粗>)

正常来说,Quill 对类的返回值都采用的简单类型(即:基础数据类型); 不过我采用了复杂的数值返回,是为了方便对值的直接调用,而不用每次都去做转换。 这个看个人意愿。

// 本质上 Quill 是有内置事件类的,因为改动大,所以需要覆盖掉内置的下划线执行类。
export function registerUnderlineFormats(Quill: any) {
  const Underline = Quill.import('formats/underline');

  class UnderlineStyleBlot extends Underline {
    static blotName = 'underline'; // 格式名称
    static tagName = 'u'; // 使用 <u> 标签
    
    static create(value: any) {
      const node = super.create();
      node.style.textDecorationLine = 'underline'; // 默认下划线

      // 回退时会传入一个对象
      if (typeof value === 'object') {
        Object.assign(node.style, {
          textDecorationStyle: value.style,
          textDecorationThickness: value.thickness,
          textDecorationColor: value.color,
        });
        return node;
      }

      // 对布尔值的处理
      if (typeof value === 'boolean') {
        if (value) {
          Object.assign(node.style, {
            textDecorationStyle: 'solid',
          });
        } else {
          // 如果是 false,移除下划线样式
          Object.assign(node.style, {
            textDecoration: 'none',
            textDecorationStyle: null,
            textDecorationThickness: null,
            textDecorationColor: null,
          });
          return node;
        }
      }

      // 根据 value 设置样式
      if (value === 'bold') {
        Object.assign(node.style, {
          textDecorationStyle: 'solid',
          textDecorationThickness: '0.15em',
        });
      } else if (isColorFormat(value)) {
        Object.assign(node.style, {
          textDecorationStyle: 'solid',
          textDecorationColor: value,
        });
      } else if (value) {
        Object.assign(node.style, {
          textDecorationStyle: value,
        });
      }
      return node;
    }

    /**
     * ! 这里的设计是为了保证撤销回退的功能。 因为设置的时候都是单一属性的变动,所以传入变动值是单一的,撤销回退的时候是多个属性的变动
     * ! 但是会导致传入值出现两种结构 string | object
     * ! 例如: { style: 'solid', thickness: '0.15em', color: '#000' }
     * ! 但是在设置的时候传入的值是 string
     * ! 例如: 'solid' | 'dashed' | 'wavy'
     * */
    static formats(node: HTMLElement): object | null {
      // 如果不是下划线样式
      if (node.style.textDecorationLine !== 'underline') return null;
      if (!includesStyleType(node.style.textDecorationStyle)) return null;

      return {
        color: node.style.textDecorationColor || null,
        style: node.style.textDecorationStyle || null,
        thickness: node.style.textDecorationThickness || null,
      };
    }

    /**
     * 同上注释
     */
    format(name: string, value: any) {
      if (name !== UnderlineStyleBlot.blotName) {
        super.format(name, value);
        return;
      }

      // 移除格式
      if (value === false) {
        Object.assign(this.domNode.style, {
          textDecoration: 'none',
          textDecorationStyle: null,
          textDecorationThickness: null,
          textDecorationColor: null,
        });
        return;
      }

      // 撤销下划线时会将这个值传入
      if (typeof value === 'object') {
        // 存在有效的下划线样式值
        if (!includesStyleType(value?.style)) {
          Object.assign(this.domNode.style, {
            textDecoration: 'none',
            textDecorationStyle: null,
            textDecorationThickness: null,
            textDecorationColor: null,
          });
        } else {
          Object.assign(this.domNode.style, {
            textDecoration: 'underline',
            textDecorationStyle: value.style,
            textDecorationThickness: value.thickness,
            textDecorationColor: value.color,
          });
        }
        return;
      }

      // 判断是否是颜色类型
      if (isColorFormat(value)) {
        if (!includesStyleType(this.domNode.style?.textDecorationStyle)) {
          this.domNode.style.textDecorationStyle = 'solid';
        }
        this.domNode.style.textDecorationColor = value;
        return;
      }

      // 先清空旧样式
      Object.assign(this.domNode.style, {
        textDecorationStyle: '',
        textDecorationThickness: '',
      });

      // 再根据实际样式设置属性值
      if (value === 'bold') {
        Object.assign(this.domNode.style, {
          textDecorationStyle: 'solid',
          textDecorationThickness: '0.15em',
        });
      } else if (value) {
        this.domNode.style.textDecorationStyle = value;
      }
    }
  }
  // 需要覆盖掉内置逻辑,这个地方就要回顾一下 我在config 的时候 handlers 里面是设置了 underline 的回调的,是为了避免内置的underline指令执行
  Quill.register('formats/underline', UnderlineStyleBlot, true);
}

菜单部分就看 UI 如何设计,自己添加就好了。 这样之后,调用也就比较常规

// editor 是 Quill 的实例
// 设置样式:css 支持的属性有哪些就可以传那些值 
editor.format('underline', 'solid');
editor.format('underline', 'dashed');
editor.format('underline', 'double');
// 设置颜色
editor.format('underline', '#fff');

// 取消下划线
editor.format('underline', false);

得到的结果

image.png

节点上的内容

image.png

行高自定义模块

行高比较常规, 没什么好说的

export function registerLineHeightBlots(Quill: typeof import('quill')['default']) {
  const Block = Quill.import('blots/block') as typeof import('parchment').BlockBlot;
  class LineHeightBlot extends Block {
    static blotName = 'line-height';
    static tagName = 'p';

    static create(value: any) {
      const node = super.create();
      node.style.lineHeight = value;
      return node;
    }

    static formats(node) {
      return node.style.lineHeight || null;
    }

    format(name: string, value: any) {
      if (name === 'line-height' && value) {
        this.domNode.style.lineHeight = value;
      } else {
        super.format(name, value);
      }
    }
  }

  Quill.register(LineHeightBlot, true);
}

// 调用
editor.format('line-height', 1);
editor.format('line-height', 2);

分割线模块

这个部分是功能中比较麻烦的部分

export function registerDividerBlots(Quill: typeof import('quill')['default']) {
  // 使用类型断言
  const QuillBlockEmbed = Quill.import('blots/block/embed') as typeof BlockEmbed;

  // 基础分割线类
  class BaseDivider extends QuillBlockEmbed {
    static blotName: string;
    static tagName: string; // 直接设置默认值
    static dividerType: DividerType;
    static isAtomic = true;
    static allowedAttributes = ['data-color', 'data-divider-type', 'class']; // 允许的属性

    static create(value: any) {
      const node = super.create() as HTMLElement;

      // 处理两种可能的数据结构
      let color: string;
      let type: string;

      // 使用默认值
      color = value?.color || '#e60000';
      type = value?.type || this.dividerType;

      node.setAttribute('contenteditable', 'false');
      node.setAttribute('data-color', color);
      node.setAttribute('data-divider-type', type);
      node.classList.add('ql-custom-divider');
      return node;
    }

    static value(node: HTMLElement) {
      return {
        color: node.getAttribute('data-color') || '#e60000',
        type: node.getAttribute('data-divider-type') || this.dividerType,
      };
    }

    static formats(node: HTMLElement) {
      return {
        color: node.getAttribute('data-color') || '#e60000',
        type: node.getAttribute('data-divider-type') || this.dividerType,
      };
    }

    format(name: string, value: any) {
      if (name === 'color' && value) {
        this.domNode.setAttribute('data-color', value);
        this.updateColor(value);
      }
    }

    protected updateColor(color: string): void {}
  }

  // 单实线基类
  class SingleLineDivider extends BaseDivider {
    static lineHeight: number;

    static create(value: any) {
      const node = super.create(value);
      const color = typeof value === 'object' ? value.color : '#e60000';
      const self = this as unknown as typeof SingleLineDivider & ISingleLineDividerBlot;

      // 容器样式
      Object.assign(node.style, {
        height: 'auto',
        padding: `3.75pt 0 ${(5 - self.lineHeight) * 0.75}pt`,
        width: '100%',
      });

      // 使用 hr 替代 div
      const line = document.createElement('hr');
      Object.assign(line.style, {
        height: `${self.lineHeight * 0.75}pt`,
        border: 'none',
        color,
        borderTop: `${self.lineHeight * 0.75}pt solid ${color}`,
        width: '100%',
        size: `${self.lineHeight * 0.75}pt`,
        margin: 0,
      });

      line.setAttribute('contenteditable', 'false');
      line.setAttribute('size', `${self.lineHeight * 0.75}pt`);
      line.classList.add('ql-divider-line');
      node.appendChild(line);

      return node;
    }

    html() {
      const node = this.domNode as HTMLElement;
      const type = node.getAttribute('data-divider-type');
      const color = node.getAttribute('data-color');
      const self = this as unknown as typeof SingleLineDivider & ISingleLineDividerBlot;

      const getHrHtml = (type: DividerType, color: string) => {
        const baseStyle = `margin: 0; border: none; width: 100%; height: ${self.lineHeight * 0.75}pt; size: ${
          self.lineHeight * 0.75
        }pt`;
        return `<hr class="ql-divider-line" contenteditable="false" style="${baseStyle} border-top: ${
          self.lineHeight * 0.75
        }pt solid ${color};">`;
      };

      const html = `<section class="ql-custom-divider" 
                    data-divider-type="${type}" 
                    data-color="${color}" 
                    contenteditable="false"
                    style="padding: 3.75pt 0 3pt; position: relative;">
                    ${getHrHtml(type as DividerType, color)}
                  </section>`;

      return html;
    }

    protected updateColor(color: string) {
      const line = this.domNode.querySelector('.ql-divider-line') as HTMLHRElement;
      if (line) {
        line.style.borderTopColor = color;
      }
    }
  }

  // 双实线基类
  class DoubleLineDivider extends BaseDivider {
    static containerHeight: string | number;
    static lineHeights: [number, number];
    static gap: number;

    static create(value: any) {
      const node = super.create(value);
      const color = typeof value === 'object' ? value.color : '#e60000';
      const self = this as unknown as typeof DoubleLineDivider & IDoubleLineDividerBlot;

      Object.assign(node.style, {
        height: 'auto',
        padding: `3.75pt 0 ${(5 - self.lineHeights[1]) * 0.75}pt`,
        width: '100%',
      });

      // 使用两个 hr 创建双线
      self.lineHeights.forEach((height: number, index: number) => {
        const line = document.createElement('hr');
        Object.assign(line.style, {
          height: `${height * 0.75}pt`,
          border: 'none',
          color,
          borderTop: `${height * 0.75}pt solid ${color}`,
          width: '100%',
          size: `${height * 0.75}pt`,
          margin: 0,
          marginBottom: index === 0 ? `${(self.gap - height) * 0.75}pt` : undefined,
        });

        line.setAttribute('contenteditable', 'false');
        line.setAttribute('size', `${height * 0.75}pt`);
        line.classList.add('ql-divider-line');
        node.appendChild(line);
      });

      return node;
    }

    html() {
      const node = this.domNode as HTMLElement;
      const type = node.getAttribute('data-divider-type');
      const color = node.getAttribute('data-color');
      const self = this as unknown as typeof DoubleLineDivider & IDoubleLineDividerBlot;

      const getHrHtml = (type: DividerType, color: string) => {
        const baseStyle = `margin: 0; border: none; width: 100%;`;

        const lines = self.lineHeights.map((height: number, index: number) => {
          const lineStyle = `${baseStyle} border-top: ${height * 0.75}pt solid ${color}; ${
            index === 0 ? `margin-bottom: ${(self.gap - height) * 0.75}pt;` : ''
          }; size: ${height * 0.75}pt; height: ${height * 0.75}pt`;

          return `<hr class="ql-divider-line" 
            contenteditable="false" 
            size="${height * 0.75}pt" 
            style="${lineStyle}">`;
        });

        return lines.join('\n');
      };

      const html = `<section class="ql-custom-divider" 
                    data-divider-type="${type}" 
                    data-color="${color}" 
                    contenteditable="false"
                    style="padding: 3.75pt 0 ${(5 - self.lineHeights[1]) * 0.75}pt; position: relative;">
                    ${getHrHtml(type as DividerType, color)}
                  </section>`;

      return html;
    }

    protected updateColor(color: string) {
      const lines = this.domNode.querySelectorAll('hr');
      lines.forEach((line: HTMLHRElement) => {
        line.style.borderTopColor = color;
      });
    }
  }

  // 具体分割线实现类
  class SingleThinDivider extends SingleLineDivider {
    static dividerType = 'single-thin' as const;
    static lineHeight = 1; // 修改为纯数字
  }

  class SingleMediumDivider extends SingleLineDivider {
    static dividerType = 'single-medium' as const;
    static lineHeight = 2; // 修改为纯数字
  }

  class DoubleThinDivider extends DoubleLineDivider {
    static dividerType = 'double-thin' as const;
    static containerHeight = 'auto';
    static lineHeights = [1, 1] as [number, number];
    static gap = 2; 
  }

  class DoubleMediumDivider extends DoubleLineDivider {
    static dividerType = 'double-medium' as const;
    static containerHeight = 'auto';
    static lineHeights = [3, 3] as [number, number];
    static gap = 2;
  }

  class DoubleThickThinDivider extends DoubleLineDivider {
    static dividerType = 'double-thick-thin' as const;
    static containerHeight = 'auto';
    static lineHeights = [3, 1] as [number, number];
    static gap = 2;
  }

  class DoubleThinThickDivider extends DoubleLineDivider {
    static dividerType = 'double-thin-thick' as const;
    static containerHeight = 'auto';
    static lineHeights = [1, 3] as [number, number];
    static gap = 2;
  }

  type DividerClass =
    | typeof SingleThinDivider
    | typeof SingleMediumDivider
    | typeof DoubleThinDivider
    | typeof DoubleMediumDivider
    | typeof DoubleThickThinDivider
    | typeof DoubleThinThickDivider;

  // 注册所有分割线类型
  const dividerClasses: DividerClass[] = [
    SingleThinDivider,
    SingleMediumDivider,
    DoubleThinDivider,
    DoubleMediumDivider,
    DoubleThickThinDivider,
    DoubleThinThickDivider,
  ];

  // 注册所有Blot
  dividerClasses.forEach((DividerClass) => {
    DividerClass.blotName = `divider-${DividerClass.dividerType}`;
    DividerClass.tagName = 'section';

    Quill.register(DividerClass, true);
  });
}


/**
 * 插入分割线
 * @param quill Quill编辑器实例
 * @param type 分割线类型
 * @param color 分割线颜色
 */
export function insertDivider(quill: Quill, type: DividerType, color = '#e60000') {
  if (!quill) return;

  const range = quill.getSelection(true);
  quill.insertText(range.index, '\n', Quill.sources.USER);
  quill.insertEmbed(range.index + 1, `divider-${type}`, { color }, Quill.sources.USER);
  quill.setSelection(range.index + 2, 0, Quill.sources.SILENT);
}

// 调用 
insertDivider(editor, type, color); 

因为这个需求是 Quill 内完全不支持的功能,所以为了解决 Quill 一些内部机制限制下,需要做很多额外的支持。

使用的节点是 section 并不是 div、p 等其他的块级元素; 原因: Quill 内部有匹配和合并机制。 如果使用内部已经使用过的节点,Quill 内部会对属性进行合并处理。这个机制是完全避免不了的,所以为了脱离这个合并机制,采用 section 的块级标签。 但是如果使用了 Quill 内置不支持的标签,就会产生额外处理。 如 html 函数, 需要增加输出节点函数,这样 Quill 在外部调用 getHtml 函数的时候,才能正确获取。

并且对下划线和分割线,需要处理复制的问题(这也是为什么分割线要采用section标签的原因之一),感兴趣的可以深度了解一下 Quill 的 matcher 匹配机制。

 // 增加对分割线标签的独立处理
  quill.clipboard.addMatcher('section', (node: HTMLElement, delta) => {
    const dividerType = node.getAttribute('data-divider-type');
    const color = node.getAttribute('data-color');

    if (!dividerType || !color) {
      return delta;
    }

    // 只返回分割线内容,不附加换行
    return new Delta().insert(
      {
        [`divider-${dividerType}`]: {
          color,
          type: dividerType,
        },
      },
      {
        color,
        type: dividerType,
      }
    );
  });
  
  quill.clipboard.addMatcher('u', (node: HTMLElement, delta) => {
    // 检查是否包含下划线样式
    const hasUnderline = node.style.textDecorationStyle;

    // 获取完整的下划线样式属性
    const underlineAttributes = hasUnderline
      ? {
          color: node.style.textDecorationColor || null,
          style: node.style.textDecorationStyle || null,
          thickness: node.style.textDecorationThickness || null,
        }
      : null;

    // 如果没有下划线样式,返回原始delta
    if (!hasUnderline) return delta;

    // 创建新的delta并合并属性
    const newDelta = new Delta();
    delta.ops.forEach((op) => {
      if (op.insert) {
        const attributes = {
          ...op.attributes,
          underline: underlineAttributes, // 使用包含所有样式的对象
        };
        newDelta.insert(op.insert, attributes);
      } else {
        newDelta.push(op);
      }
    });

    return newDelta;
  });

结果

image.png

以上就是3个自定义功能的主要代码部分,Quill 作为一个很久之前的富文本插件,他的设计还是比较合理的,虽然功能并不是那么丰富,不过还是够用了。

如果有小伙伴在使用的过程中遇到想扩展但是没有头绪可以以此做个参考。

组件通讯要注意内存泄露

2025年7月1日 14:27

作者:程序员成长指北 原文;mp.weixin.qq.com/s/ymcR2ISHb…

在 Vue 中,兄弟组件之间的通信可以通过多种方式实现,以下是一些常用的方法:

1、 使用父组件作为中介(或状态提升)

•兄弟组件可以通过父组件进行通信。一个兄弟组件可以通过事件将数据发送到父组件,父组件再将数据传递给另一个兄弟组件。

•实现步骤:

1.在第一个兄弟组件中,使用 $emit 触发一个事件,将数据传递给父组件。2.在父组件中,监听该事件并接收数据。3.在父组件中,将接收到的数据通过 props 传递给另一个兄弟组件。

2、 使用事件总线

•事件总线是一种轻量级的发布-订阅模式,通过创建一个空的 Vue 实例作为事件中心,兄弟组件可以在事件中心上监听和触发事件。•实现步骤:

1.创建一个新的 Vue 实例,并将其导出作为事件总线。2.在一个兄弟组件中,使用 $emit 在事件总线上触发事件。3.在另一个兄弟组件中,使用 $on 在事件总线上监听该事件。

// eventBus.jsimport Vue from 'vue';export const EventBus = new Vue();
// BrotherComponentA.vueimport { EventBus } from './eventBus.js';EventBus.$emit('event-name', data);
// BrotherComponentB.vueimport { EventBus } from './eventBus.js';EventBus.$on('event-name', (data) => { // Handle data});

在使用 EventBus 时,如果不正确地管理事件监听器,可能导致内存泄露。特别是在组件销毁时,如果没有移除监听器,组件可能无法被垃圾回收,从而导致内存泄露。

为了避免这种问题,在组件销毁时应该移除事件监听器。可以在组件的 beforeDestroy 钩子(Vue 2)或 onBeforeUnmount 钩子(Vue 3)中调用 $off 方法来移除监听器。

// BrotherComponentB.vueimport { EventBus } from './eventBus.js';
export default {  created() {    EventBus.$on('event-name'this.handleEvent);  },  beforeDestroy() {    EventBus.$off('event-name'this.handleEvent);  },  methods: {    handleEvent(data) {      // Handle the event    }  }}

通过在组件销毁时移除事件监听器,可以有效地避免内存泄露问题。这是使用事件总线时的一个重要实践,确保应用程序的性能和稳定性。

你也想成为高手么,那就像高手一样调试程序

2025年7月1日 14:25

一个关于如何高效调试软件的指南,强调采取 detective mindset、熟练掌握工具、善用 print 函数、团队协作共享调试经验以及利用 rubber duck debugging 等方法来提高调试效率。今日前端早读课文章由 @Juhis 分享,@飘飘编译。

译文从这开始~~

像侦探一样思考

一个优秀的调试者,应该像侦探一样去面对问题。优秀的侦探不会凭空猜测或做出假设,而是一步步追踪线索,仔细检查证据,并通过逻辑推理得出结论。

我经常看到无论是初级开发者还是资深工程师,面对问题时要么死盯着代码希望奇迹出现,要么凭经验跳到结论,觉得自己知道问题在哪。结果就是浪费好几个小时,最后发现自己完全在错误的方向上兜圈子。

图片

💡 提示 1:像一个对这段代码一无所知的人那样,按部就班、逐步排查。

  • 确认你正在查看的代码确实被执行了。一个简单的方法是在你检查的每个函数开头加一条 print (或 console.log)语句,确认代码是否运行。
  • 重现 bug。你必须知道怎么触发问题,否则你也无法确认问题是否已解决。
  • 检查变量状态:打印变量的值,或者使用调试工具一步步查看发生了什么。

我发现,每当我在代码库中 “移动” 思考时,反复执行这三个步骤,几乎总能找到 bug 的根源。

对于前端开发者来说,很多 bug 来自异步操作,比如:

  • Promise 未正确返回;
  • await 被遗漏;
  • UI 更新顺序混乱。

此时可以用 Chrome DevTools 的异步堆栈跟踪来帮助我们厘清问题:

  • 在 DevTools 的 Source 面板中打开 Call Stack;
  • 勾选 “Enable async stack traces”;
  • 设置断点,观察异步逻辑是如何一步步触发的。

这比单纯依靠 “猜测 Promise 哪里没返回” 更有效。

投资时间学习你的工具

软件开发行业的工具五花八门,而且大多数工具都是免费的或开源的,几乎没有什么金钱成本。但你需要花时间去学习和掌握它们。

我认为调试是一种双重技能:一方面是前面讲到的思维方式和流程,另一方面就是强大的工具,只有两者结合,才能高效地发现并解决问题。

在日常工作中,我们很容易停留在自己熟悉的那一套工具里,不去了解别的选择,不去查看更新日志,也不知道其他人都在用什么工具解决类似的问题。

💡提示 2:熟练掌握你的语言中的打印、日志、调试器和代码编辑器。这样你就能专注于解决问题,而不是浪费精力在 “怎么用工具” 上。

对于前端开发者来说,除了 console.log,还有许多更高效的调试手段:

📍 断点调试(Breakpoints)

  • 行断点 - 定位特定语句;
  • 条件断点 - 只在变量满足条件时中断;
  • DOM 断点 - 监听元素变化;
  • XHR 断点 - 请求发出时暂停。

在 Chrome DevTools 的 Source 面板中,这些断点设置都非常直观。

⏱ 性能调试(Performance)

  • 用 Performance 面板录制页面行为;
  • 分析 JS 执行时间、Layout 重排;
  • 找出动画卡顿、长任务等瓶颈。

适用于页面流畅度优化和交互延迟排查。

🌐 网络调试(Network)

  • 查看请求是否发出、返回是否正常;
  • 检查 status code、header 和响应数据;
  • 发现跨域问题、缓存设置错误等问题。

例如你遇到 “登录请求发了但页面没跳转”,可以通过 Network 面板确认请求返回是否正常,再去检查 JS 逻辑是否响应了返回结果。

在 Python 中,调试工具有 built-in debugger、PuDB、web-pdb、birdseye 等。在前端,除了 DevTools,也可以结合 VS Code 的 Debug 面板进行跨项目调试。

不断探索这些工具,并根据自己的项目特点选择最顺手的一种,是非常值得投入时间的事情。

打印是最好的调试起点

图片

我在各个地方写文章或演讲时,关于调试我最常遇到的一个反馈就是:很多人对使用打印语句(print)调试存在偏见。

有不少人告诉我,他们的同事说 “打印是不好的做法”,但在我讲完之后,也有人跑来和我说 “太高兴有人告诉我,打印其实不是错的”。

打印是最好的调试工具,因为它几乎没有门槛。你不需要安装或配置任何东西,写一句打印语句只要几秒钟,也很容易在输出中找到结果。而且如果问题本身不复杂,这绝对是最快的方式。

💡提示 3:不要吝啬使用 print!

你越愿意用打印,调试时就越容易上手,而不是陷入死盯代码或胡乱猜测的误区。当然,打印不是最强大的调试手段,也不是最复杂的工具,有时候它不够用 —— 这时候你可以升级到更强的调试器。但打印是你调试之旅最好的起点。

与同事分享经验,共同成长

Jenn Creighton 在她的演讲《Now And Then: Debugging Async JS》中说得很好:

“最好的调试工具,是你对某个东西的理解。”

她提到,长期使用某种技术、某种方法或某个代码库后,会积累很多经验。当你再遇到问题时,大脑会下意识地想起以前的经历,帮你更快判断该先验证哪些假设。

这种经验,很多时候是个人积累的。但其实,它完全可以共享出来。就像我鼓励大家分享工具使用心得一样,我也鼓励你把调试 bug 的经验分享给项目组或团队成员。

💡提示 4:记录下你的调试经历,偶尔组织一次午餐分享会或周五学习时间,讲讲你遇到的 bug 和解决过程。

这些故事不需要是什么重大事故,也不用是 “英雄行为”,而是日常遇到的普通问题。如果我从五位同事那里听到与 “时区问题” 相关的调试经历,当我真的遇到时区 bug 的时候,自然就能更有准备。

你可以尝试以下方式进行知识共享:

  • 在团队的 Wiki、Notion、Confluence 上建立调试经验库;
  • 每周组织一次 “Bug Friday” 或 “午餐分享”,讲讲你遇到的 bug;
  • 总结典型坑,比如跨域失败、异步更新顺序、时区相关错误等。

📄 调试经验记录模板参考:

 【问题描述】登录接口返回 200 但页面无跳转

 【触发方式】登录后点击按钮,开发环境正常,线上异常

 【定位过程】

   - 检查 Network,请求返回正常
   - 发现 login 函数中 Promise 没有 await
   - 修复后跳转恢复正常

 【复盘总结】

 需加强对异步流程的规范管理,避免状态未更新时提前进行跳转逻辑

跟橡皮鸭说话

如果你真的尝试了所有办法还是卡住了,那就请出橡皮鸭吧。橡皮鸭调试(Rubber duck debugging)虽然在行业里有点像是个玩笑,但它其实真的是一种非常有效的方法。

图片

简单来说,方法如下:

  • 拿一只橡皮鸭(没有鸭也可以用别的玩具动物,实在不行就拉同事来)
  • 把你的问题完整地说出来;
  • 告诉它你已经尝试过哪些方法、为什么没成功
  • 向它寻求建议

我的经验是:往往在鸭子还没 “回答” 之前,你自己就已经想出解决办法了。

💡提示 5:多跟鸭子说话,或者写下来你的想法,帮助理清思路、发现隐藏的假设漏洞。

这是因为我们大脑在自己思考时很容易 “骗自己”。当你自己和自己对话时,大脑会跳过一些细节。你可能觉得 “我已经试过了”,但其实你跳过了某些步骤,因为它 “看起来太明显”。当你必须用语言讲出来,就会强迫自己梳理所有细节。这时候,缺失的步骤就很容易暴露出来了。

找到这些 “漏掉的步骤”,通常就能帮助你发现问题所在,至少也能帮你推进一步。

鸭子议会:向自然取经

图片

我以前住在柏林时,附近一棵树下住着一群野鸭。我称它们为 “Rummelsburg 鸭子议会”。当我自己手边的三只橡皮鸭不够用了,我会去散步顺便 “向议会请教”。

这当然是一种幽默表达,但它背后的原则是真实有效的:调试的本质就是不断验证假设,不断缩小问题范围。

🦆!

最后总结

调试不仅仅是修复 bug,更是一项核心的开发技能。通过本文你应该能意识到:

  • 思维方式 > 工具;
  • 简单方法(如打印)也很有价值;
  • 工具熟练度和团队交流能极大提升调试效率;
  • 问题不可怕,找到它的方法才重要。

关于本文
译者:@飘飘
作者:@Juhis
原文:flaky.build/debug-like-…

❌
❌