普通视图

发现新文章,点击刷新页面。
今天 — 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日首页

手写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指向问题,再也不用慌啦~ 😉

🧱 优雅封装 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

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-…

前端登录token到底应该存在哪?LocalStorage、SessionStorage还是Cookie?一篇说透!

作者 ErpanOmer
2025年7月1日 14:10

如果你做过任何需要登录的功能,那么你一定思考过这个问题:当后端甩给我一个token时,我一个前端,到底应该把它放在哪儿?

这个问题看似简单,无非就是 LocalStorageSessionStorageCookie 三个选项。但如果我告诉你,一个错误的选择,可能会直接导致你的网站出现严重的安全漏洞,你是不是会惊出一身冷汗?

许多开发者(包括曾经的我)不假思索地把token塞进LocalStorage,因为它的API最简单好用。但这种方便的背后,隐藏着巨大的风险。

今天,这篇文章将带你彻底终结这个纠结。我们将深入对比这三位“候选人”的优劣,剖析它们各自面临的安全威胁(XSSCSRF),并最终给出一个当前业界公认的最佳实践方案。


1. 三种存储方案对比

在做决定前,我们先来快速了解一下这三个Web存储方案的基本特性。

特性 LocalStorage SessionStorage Cookie
生命周期 永久,除非手动清除 页面会话期间(标签页关闭即失效) 可设置过期时间
存储大小 约 5MB 约 5MB 约 4KB
JS可访问性 可访问 可访问 可访问(除非设置HttpOnly
与服务端通信 不会自动发送 不会自动发送 每次HTTP请求都会自动携带

一目了然,LocalStorageSessionStorage是HTML5提供的新API,更大、更易用。而Cookie是“老前辈”,小而精,并且有个独一无二的特性:会自动“粘”在HTTP请求头里发给后端。


2. 两大安全攻击 XSS 与 CSRF

选择存储方案,本质上是在权衡安全和便利。而威胁token安全的主要是下面两种。

XSS (跨站脚本攻击)

  • 手法:攻击者通过某种方式(比如评论区)向你的网站注入了恶意的JavaScript脚本。当其他用户访问这个页面时,这段脚本就会执行。
  • 目标:如果你的token存在LocalStorageSessionStorage里,那么这段恶意脚本就可以通过简单的localStorage.getItem('token')轻松地把它偷走,然后发送到攻击者的服务器。token失窃,你的账户就被冒充了。

结论一:LocalStorageSessionStorage 对 XSS 攻击是完全不设防的。只要你的网站存在XSS漏洞,存在里面的任何数据都能被轻易窃取。

CSRF (跨站请求伪造)

  • 手法:你刚刚登录了你的银行网站bank.com,你的登录凭证(Cookie)被浏览器记住了。然后,你没有关闭银行页面,而是点开了一个恶意网站hacker.com。这个恶意网站的页面里可能有一个看不见的表单或<img>标签,它会自动向bank.com/transfer这个地址发起一个转账请求。
  • 目标:因为浏览器在发送请求到bank.com时,会自动带上bank.comCookie,所以银行服务器会认为这个请求是你本人发起的,于是转账就成功了。你神不知鬼不觉地被“伪造”了意愿。

结论二:Cookie 如果不加以保护,会受到 CSRF 攻击的威胁。


3. 现代Cookie的“优势”

看到这里你可能会想:LocalStorage防不住XSS,Cookie防不住CSRF,这可怎么办?

别急,我们的Cookie经过多年的进化,已经有了强大的防止手段。

HttpOnly - 封印JS的访问

如果在设置Cookie时,加上HttpOnly属性,那么通过JavaScript(如 document.cookie)将无法读取到这个Cookie

Set-Cookie: token=...; HttpOnly

这意味着,即使网站存在XSS漏洞,攻击者的恶意脚本也偷不走这个Cookie,从根本上阻断了XSS利用token的路径。

SameSite - 防止携带

SameSite属性用来告诉浏览器,在跨站请求时,是否应该携带这个Cookie。它有三个值:

  • Strict:最严格。只有当请求的发起方和目标网站完全一致时,才会携带Cookie,能完全防御CSRF。
  • Lax:比较宽松(现在是大多数浏览器的默认值)。允许在“顶级导航”(如<a>链接、GET表单)的跨站请求中携带Cookie,但在<img><iframe>、POST表单等“嵌入式”请求中会拦截。这已经能防御大部分CSRF攻击了。
  • None:最松。任何情况下都携带Cookie。但必须同时指定Secure属性(即Cookie只能通过HTTPS发送)。

对于登录token,我们通常希望它尽可能安全,所以SameSite=Strict是最佳选择。

Secure - 保证传输安全

这个属性很简单,只要设置了它,Cookie就只会在HTTPS的加密连接中被发送,可以防止在传输过程中被窃听。


4. 终极答案

综合以上所有分析,我们终于可以给出当前公认的最佳、最安全的方案了。

这个方案的核心是“组合拳”:将不同生命周期的token存放在不同的地方,各司其职。

我们通常有两种token

  • AccessToken:生命周期很短(如15分钟),用于访问受保护的API资源。
  • RefreshToken:生命周期很长(如7天),专门用来在AccessToken过期后,换取一个新的AccessToken

最佳存储策略如下:

  1. RefreshToken: 存放在一个 HttpOnly=true, Secure=true, SameSite=StrictCookie中。

    • 为什么? RefreshToken非常关键且长期有效,所以必须用最安全的方式存储。HttpOnly让它免受XSS攻击,SameSite=Strict让它免受CSRF攻击。前端 JS 完全接触不到它,只在需要刷新token时,由浏览器自动带着它去请求/refresh_token这个特定接口。
  2. AccessToken: 存放在 JavaScript的内存中(例如,一个全局变量、React Context或Vuex/Pinia等状态管理库里)。

    • 为什么? AccessToken需要被JS读取,并放在HTTP请求的Authorization头里(Bearer xxx)发送给后端。将它放在内存中,可以避免XSS直接从LocalStorage里扫荡。当用户关闭标签页或刷新页面时,内存中的AccessToken会丢失。
    • 丢失了怎么办? 这就是RefreshToken发挥作用的时候了。当应用启动或AccessToken失效时,我们就向后端发起一个请求(比如访问/refresh_token接口),浏览器会自动带上我们安全的RefreshToken Cookie,后端验证通过后,就会返回一个新的AccessToken,我们再把它存入内存。

这个方案完美地结合了安全性和可用性,几乎无懈可击。

一张表格说透

存储方式 优点 缺点(安全风险) 推荐用法
LocalStorage API简单,容量大,持久 XSS 不推荐存储敏感信息(如Token)
SessionStorage API简单,标签页关闭即删 XSS 同上
Cookie 可自动发送,可配置安全属性 CSRF (若无SameSite) 不推荐直接存AccessToken
内存 + HttpOnly Cookie 安全 (防XSS+CSRF), 体验好 方案略复杂 最佳实践 (AccessToken存内存,RefreshTokenHttpOnly Cookie)

希望这篇文章能彻底帮你理清思路。当你在实践中或者面试被问到时,就可以把这套“方案”发挥出来。

谢谢大家🙂

📌 你可以继续看我的系列文章

JavaScript异步编程的五种方式

2025年6月30日 23:34

1、什么是异步?

异步可以理解为把一个任务分成了两段,先执行第一段,转而去执行其它任务,等准备完毕后,再回过头来执行第二段。

比如发一个 http 请求,第一段就是创建 XMLHttpRequest 对象,做好请求配置后向后端发送请求,然后就去执行其他任务(其它 js 代码)了,第二段就是拿到后端响应后,执行对应的回调函数。

这种不连续的执行,叫做异步,反之,连续的执行,叫做同步。

2、高阶函数

在 js 中,函数是一等公民。所谓一等公民,就是指函数能和数据类型一样,可以赋值给变量,也可以作为函数的参数和返回值。而传入的参数中或者返回值中带有函数的,就被称为高阶函数

3、高阶函数的作用

3.1 可以批量生成函数

function isType(type) {
  return (obj) => {
    return Object.prototype.toString.call(obj) === `[object ${type}]`;
  };
}

const isFunc = isType("Function");
const isBoo = isType("Boolean");

通过高阶函数isType,可以生成不同类型的判断函数,判断各种对象类型。

3.2 可以用于多次调用才执行的函数

function after(time, callback) {
  let count = 0;
  return function() {
    if (++count === time) {
      callback.apply(this, arguments);
    }
  };
}
const f = after(3, function() {
  console.log("callback");
});
f();

after函数的作用是,在调用f函数三次之后,才会执行回调函数callback

4、异步编程的五种实现方式

  1. 回调函数
  2. 事件监听,发布订阅
  3. Promise
  4. generator/yield
  5. async/await

4.1 回调函数

回调函数是指将异步的第二段放在回调函数里面,等准备完毕后,执行回调。

const fs = require("fs");
fs.readFile("./1.txt", "utf8", function(err, data) {
  console.log(err, data);
});

回调函数的问题:

  1. 异常处理麻烦

异步代码不能用 try catch 捕获异常,所以如果出错了要向回调函数传入异常供调用者判断。比如在 nodejs 中,对异常处理有一个约定,会将异常作为回调的第一个参数返回,如果为 null 则表示没有出错。

  1. 容易形成回调地狱

异步多级依赖的情况下代码会嵌套的很深,不利于阅读和维护。

const fs = require("fs");
fs.readFile("./1.txt", "utf8", function(err1, result1) {
  fs.readFile(result1, "utf8", function(err2, result2) {
    fs.readFile(result2, "utf8", function(err3, result3) {
      console.log(result3);
    });
  });
});

4.2 事件发布/订阅模型

发布/订阅是一种设计模式,它依赖于一个事件调度中心,先注册事件名和回调(on),然后可以主动触发(emit)。

const EventEmitter = require("events");
// eve为事件调度中心
const eve = new EventEmitter();
eve.on("first", function() {
  console.log("first");
});
eve.emit("first");

4.3 Promise

Promise 本意是承诺,在程序中的意思就是承诺我过一段时间后给你一个结果,而过一段时间指的就是异步操作,比如网络请求、定时器,读取文件等。

const p = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("success");
  }, 1000);
});
p.then((result) => {
  // 1秒后打印success
  console.log(result);
});

4.4 Generator/yield

Generator函数是ES6提供的一种异步编程解决方案。

语法上:可以将它理解为一个状态机,其内部封装了多种状态。执行Generator函数会返回一个遍历器对象,可以依次迭代Generator函数内部的每一个状态,也被称为迭代器

  1. Generator的使用
function *test() {
    const first = yield 1
    console.log(first)
    const second = yield 2
    console.log(second)
}
const it = test()
console.log(it.next())
console.log(it.next('first'))
console.log(it.next('second'))
// { value: 1, done: false }
// first
// { value: 2, done: false }
// second
// { value: undefined, done: true }

注意:第一次调用next传递参数没有意义,done的值为false表示函数调用结束了

形式上,Generator函数相对于普通函数而言,多了两个特性。一是function 关键字和函数名之间有一个星号,函数内部使用yield表达式,定义不同的内部状态。yield的意思是产出,可以将状态传递出去。

  1. CO

co 是一个为nodejs和浏览器打造的基于生成器的流程控制工具,借助Promise,可以使用更加优雅的方式编写非阻塞代码。

const fs = require('fs')
function readFile(filename) {
    return new Promise((resolve, reject) => {
        fs.readFile(filename, 'utf8', (err, data) => {
            err ? reject(err) : resolve(data)
        })
    })
}
/** 文件1.txt里面的内容是2.txt 
 *  文件2.txt里面的内容是222
*/
function *read() {
    const filename = yield readFile('1.txt')
    const result = yield readFile(filename)
    return result
}
co(read).then(result => {
    console.log(result)
}).catch(err => {
    console.log(err)
})
// 最终打印222

co 实现原理如下:

// co实现原理
function co(gen) {
    const it = gen()
    return new Promise((resolve, reject) => {
        !(function next(nextValue) {
            const { value, done } = it.next(nextValue)
            if (!done) {
                value.then(next, reject)
                
            } else {
                resolve(value)
            }
        })()
    })
}

4.5 async/await

async/awaitES7 的语法,是js异步编程的终极解决方案。可以理解为是Promise+Generator的语法糖,可以轻松做到Generator和co所做到的工作。

const fs = require('fs')
function readFile(filename) {
    return new Promise((resolve, reject) => {
        fs.readFile(filename, 'utf8', (err, data) => {
            err ? reject(err) : resolve(data)
        })
    })
}
async function read() {
    const filename = await readFile('1.txt')
    const result = await readFile(filename)
    return result
}
read().then(result => {
    console.log(result)
})

优点:

  1. 内置执行器,内部会帮你进行迭代。
  2. 更好的语义,代码结构清晰
  3. 更广的适用性,await后面不一定要跟promise

缺点:

  1. 滥用await会导致性能问题,因为await会阻塞代码,也许后者不一定依赖前者,但仍需要等待前者完成,导致代码失去了并发性。
  2. 错误处理麻烦,项目中可以用axios拦截器处理错误或者用try catch来捕获错误。
  3. 经过babel编译后的代码臃肿。

JavaScript forEach 方法详解:原数组会被改变吗?

作者 烛阴
2025年6月30日 21:53

1. 什么是 forEach

forEach 是JavaScript数组的一个内置方法,用于对数组中的每个元素执行指定的函数。它的基本语法如下:

array.forEach(callback(currentValue, index, array), thisArg);
  • callback: 要执行的函数,接收三个参数:当前元素的值、当前元素的索引和原数组。
  • thisArg: 可选,执行回调时使用的 this 值。

2. forEach 的基本用法

以下是一个简单的 forEach 示例:

const numbers = [1, 2, 3, 4, 5];

numbers.forEach((number) => {
    console.log(number);
});

在这个例子中,forEach 遍历了 numbers 数组,并打印了每个元素的值。

3. forEach 是否会修改原数组?

forEach 本身并不会自动修改原数组。它只是遍历数组并执行回调函数。如果在回调函数中对数组的元素进行了修改,那么原数组的值将会被改变。

4. 示例:forEach 修改数组的情况

让我们看一个示例,展示如何在 forEach 中修改数组的元素:

const numbers = [1, 2, 3, 4, 5];

numbers.forEach((value, index, array) => {
    array[index] = value * 2; // 修改原数组
});

console.log(numbers); // 输出: [2, 4, 6, 8, 10]

在这个例子中,我们在 forEach 的回调函数中修改了原数组的值,将每个元素乘以2。最终,numbers 数组的值被改变了。

5. 其他数组方法与 forEach 的比较

forEach 类似的数组方法还有 mapfilterreduce。这些方法的行为各不相同:

  • map: 返回一个新数组,包含对原数组每个元素调用回调函数的结果。不会修改原数组。
  • filter: 返回一个新数组,包含所有通过测试的元素。不会修改原数组。
  • reduce: 返回一个单一值,累积所有元素的结果。不会修改原数组。

示例对比

const numbers = [1, 2, 3, 4, 5];

// 使用 map
const doubled = numbers.map((value) => value * 2);
console.log(doubled); // 输出: [2, 4, 6, 8, 10]
console.log(numbers); // 输出: [1, 2, 3, 4, 5] (原数组未修改)

// 使用 filter
const evenNumbers = numbers.filter((value) => value % 2 === 0);
console.log(evenNumbers); // 输出: [2, 4]
console.log(numbers); // 输出: [1, 2, 3, 4, 5] (原数组未修改)

// 使用 reduce
const sum = numbers.reduce((acc, value) => acc + value, 0);
console.log(sum); // 输出: 15
console.log(numbers); // 输出: [1, 2, 3, 4, 5] (原数组未修改)

6. 总结

forEach 方法本身不会修改原数组,但如果在回调函数中对数组元素进行了修改,那么原数组的值将会被改变。在需要返回新数组的场景中,建议使用 mapfilterreduce 等方法。


如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript开发干货。

昨天以前首页

移动端路由返回时的阴影残留:Vue异步渲染时序问题

2025年6月30日 19:45

一、问题本质:异步渲染与 DOM 更新时序冲突

阴影残留通常出现在以下场景:

  • 页面 A → 页面 B(路由跳转)→ 页面 A(返回)时,页面 B 的部分 UI 元素以阴影形式残留在页面 A 上

  • 本质是旧组件 DOM 未完全卸载时,新组件 DOM 已开始渲染,导致视觉层叠冲突

Vue 的异步渲染机制(Virtual DOM diff 后批量更新)在移动端会因以下原因放大问题:

  1. 移动端浏览器渲染队列优先级与 JS 执行队列竞争
  2. 路由切换动画(如滑动返回)与组件销毁 / 创建周期重叠
  3. 异步请求响应、定时器回调等操作触发旧组件 DOM 更新

二、核心原因分析(附时序图)

plaintext

┌──────────────────────────────────────────────────────────┐
│                        路由返回操作                        │
├────────────────┬────────────────┬───────────────────────┤
│  页面B beforeDestroy钩子执行   │  页面A开始创建        │
├────────────────┼────────────────┼───────────────────────┤
│  页面B 异步请求响应(此时已触发DOM更新) │  页面A beforeMount钩子执行  │
├────────────────┼────────────────┼───────────────────────┤
│  页面B destroyed钩子执行(理论上DOM应卸载) │  页面A mounted钩子执行(DOM插入) │
├────────────────┼────────────────┼───────────────────────┤
│  页面B 异步操作回调导致DOM更新(但组件已销毁) │  页面A 开始渲染DOM           │
└────────────────┴────────────────┴───────────────────────┘
               ↑                                    ↑
               └────────────────────────────────────┘
                         视觉冲突:旧DOM未消失,新DOM已叠加

三、典型触发场景与解决方案

场景 1:组件销毁后异步操作仍更新旧 DOM

javascript

// 错误示例:组件销毁后请求响应仍更新数据
export default {
  data() {
    return { list: [] }
  },
  mounted() {
    // 模拟3秒后返回的异步请求
    setTimeout(() => {
      this.list = [1, 2, 3]; // 组件销毁后此操作仍会更新DOM
    }, 3000);
  },
  beforeDestroy() {
    // 未取消异步操作!
  }
}

解决方案:使用生命周期钩子清理异步操作

javascript

export default {
  data() {
    return { 
      list: [],
      timerId: null
    }
  },
  mounted() {
    this.timerId = setTimeout(() => {
      // 仅在组件未销毁时更新数据
      if (!this._isDestroyed) {
        this.list = [1, 2, 3];
      }
    }, 3000);
  },
  beforeDestroy() {
    // 关键:清除所有异步操作
    clearTimeout(this.timerId);
  }
}
场景 2:路由切换动画与 DOM 卸载时序冲突

css

/* 错误示例:过渡动画持续时间 > 组件卸载时间 */
.page-transition-enter-active,
.page-transition-leave-active {
  transition: all 0.8s ease; /* 动画时间过长 */
}
.page-transition-leave-to {
  transform: translateX(100%);
  opacity: 0;
}

解决方案:同步动画与组件生命周期

javascript

// 路由配置中添加过渡钩子
const router = new VueRouter({
  routes: [
    {
      path: '/pageA',
      component: PageA,
      meta: { transitionName: 'slide' }
    }
  ]
});

// 全局路由守卫中控制动画时序
router.beforeEach((to, from, next) => {
  // 确保前一个页面动画完成后再跳转
  if (from.meta.transitionName) {
    const leaveEl = document.querySelector(`.${from.meta.transitionName}-leave-active`);
    if (leaveEl) {
      leaveEl.addEventListener('transitionend', next, { once: true });
    } else {
      next();
    }
  } else {
    next();
  }
});
场景 3:Vue 异步渲染队列导致新旧 DOM 重叠

javascript

// 错误示例:组件销毁前未等待DOM更新完成
export default {
  beforeDestroy() {
    this.isVisible = false; // 触发DOM更新
    // 未等待DOM更新完成就销毁组件
  }
}

解决方案:使用 nextTick 确保 DOM 更新完成

javascript

export default {
  beforeDestroy() {
    this.isVisible = false; // 标记为不可见
    
    // 等待DOM更新完成后再销毁组件
    this.$nextTick(() => {
      // 此处DOM已更新,可安全销毁
    });
  }
}

四、移动端特化优化方案

1. 硬件加速渲染层管理

css

/* 强制创建独立渲染层,避免图层残留 */
.page-component {
  transform: translateZ(0); /* 触发GPU加速 */
  will-change: transform, opacity;
}

/* 路由切换时强制重绘 */
.page-transition-leave-active {
  animation: flushLayer 0.3s ease-out;
}

@keyframes flushLayer {
  0% { opacity: 1; }
  100% { opacity: 0; transform: scale(0.9); }
}
2. 路由切换时的 DOM 清理策略

javascript

// 封装路由工具函数,主动清理残留DOM
export function clearResidualDOM() {
  // 移除所有带有特定标记的旧组件DOM
  const oldPages = document.querySelectorAll('.vue-page[data-is-old]');
  oldPages.forEach(el => {
    // 先隐藏再移除,避免重排抖动
    el.style.display = 'none';
    setTimeout(() => {
      el.remove();
    }, 300);
  });
}

// 在路由守卫中调用
router.afterEach(() => {
  clearResidualDOM();
});
3. 移动端事件循环优化

javascript

// 优化异步操作执行时机,避免阻塞渲染
export function deferRender(callback) {
  // 使用requestAnimationFrame优先处理渲染
  if (typeof requestAnimationFrame === 'function') {
    requestAnimationFrame(callback);
  } else {
    // 降级方案
    setTimeout(callback, 0);
  }
}

// 在组件中使用
deferRender(() => {
  this.list = response.data; // 确保渲染优先
});

五、完整解决方案:从请求管理到渲染优化

javascript

// 综合解决方案示例
export default {
  data() {
    return {
      isMounted: false,
      requestTasks: [], // 存储请求任务
      animationEnded: true
    }
  },
  mounted() {
    this.isMounted = true;
    
    // 发起请求并存储取消函数
    const task = this.$axios.get('/api/data').then(res => {
      if (this.isMounted) {
        this.processData(res);
      }
    });
    this.requestTasks.push(task);
  },
  beforeDestroy() {
    this.isMounted = false;
    
    // 1. 取消所有异步请求
    this.requestTasks.forEach(task => {
      if (task.cancel) task.cancel();
    });
    
    // 2. 等待动画完成
    this.animationEnded = false;
    const animationEl = this.$el.querySelector('.page-animation');
    if (animationEl) {
      animationEl.addEventListener('animationend', this.handleAnimationEnd, { once: true });
    } else {
      this.handleAnimationEnd();
    }
  },
  methods: {
    handleAnimationEnd() {
      this.animationEnded = true;
      
      // 3. 等待DOM更新完成后再彻底销毁
      this.$nextTick(() => {
        // 可在此处添加额外的DOM清理逻辑
        console.log('组件已安全销毁');
      });
    },
    processData(data) {
      // 数据处理逻辑
    }
  },
  destroyed() {
    // 确保所有资源已释放
  }
}

Express 子路由、静态资源与错误处理详解

作者 coderklaus
2025年6月30日 18:12

在实际开发中,随着项目规模扩大,路由和中间件的数量会迅速增长。如果所有路由都写在同一个文件里,代码将变得难以维护。Express 提供了子路由(Router)中间件机制,可以帮助我们实现模块化开发、静态资源托管和高效的错误处理。下面详细介绍这些内容。

一、子路由(Router)

1. 为什么要使用子路由?

当路由数量较多时,推荐将不同业务模块的路由拆分到不同文件或目录(如 routerroutes 文件夹),每个模块对应一个子路由。这样可以做到高内聚、低耦合,提升项目可维护性和可扩展性。

2. express.Router 的原理

express.Router() 本质上是一个“迷你版”的 Express 应用,拥有完整的中间件和路由系统。你可以为它单独定义路由、中间件、参数处理等,最后将其挂载到主应用的某个路径下。

3. 子路由的使用示例

import express from 'express'

const app = express()

// 创建子路由对象
const userRouter = express.Router()

// 定义子路由
userRouter.get('/list', (req, res) => {
  // req.url 是子路由基准路径之后的部分
  // req.baseUrl 是子路由的基准路径
  // req.originalUrl 是原始请求路径
  res.json({
    url: req.url,
    path: req.path,
    baseUrl: req.baseUrl,
    originalUrl: req.originalUrl,
    params: req.params,
    query: req.query
  })
})

userRouter.post('/create', (req, res) => res.end(req.url))
userRouter.patch('/:id', (req, res) => res.end(req.url))

// 挂载子路由到主应用
app.use('/users', userRouter)

app.listen(3000)

小结:

  • 子路由路径必须以 / 开头。
  • 请求到 /users/list 时,userRouter 内的 /list 路由会被匹配。
  • 这样拆分后,主文件只需负责路由挂载,具体逻辑分散在各自模块,结构清晰。

二、静态资源托管

Web 项目通常需要对外暴露静态文件(如图片、CSS、JS、上传文件等)。Express 提供了内置中间件 express.static(),可将指定目录设为静态资源目录。

1. 使用方法

import express from 'express'

const app = express()

// 设置静态资源目录(可多次调用,支持多个目录)
app.use(express.static('./uploads'))
app.use(express.static('./build'))

app.listen(3000)
  • 访问 http://localhost:3000/头像.jpg,会自动查找 uploadsbuild 目录下的 头像.jpg 文件。
  • 静态资源中间件会优先返回第一个匹配到的文件。

建议:
将上传目录、前端打包目录等都设置为静态资源目录,便于统一管理和访问。

三、错误处理机制

后端服务不可避免会遇到各种异常情况。Express 提供了灵活的错误处理机制,主要包括两种方式:

1. HTTP 状态码 + 错误信息

这是最常见的处理方式。直接返回标准 HTTP 错误码(如 404、500),并在响应体中说明错误原因。

res.status(404).send('资源未找到')
  • 优点:符合 HTTP 标准,前端可直接根据状态码判断。
  • 缺点:有时无法满足复杂业务需求。

2. 业务自定义错误码

有些团队喜欢所有接口都返回 200 状态码,通过响应体中的自定义 code 字段区分业务成功/失败。

res.json({
  code: 404,
  message: '用户不存在',
  data: null
})
  • 优点:前后端约定灵活,业务错误粒度可控。
  • 缺点:不符合 RESTful 风格,需前端配合解析。

3. 错误处理中间件

Express 允许通过特殊格式的中间件统一处理错误(即错误处理中间件)。只要在 next() 里传递参数,Express 就会跳过后续普通中间件,直接进入错误处理中间件。

import express from 'express'

const app = express()

app.use(express.json())

app.post('/login', (req, res, next) => {
  const { name, password } = req.body
  if (!name || !password) {
    // 传递错误码到错误处理中间件
    next(-1001)
  } else {
    res.send('登录成功')
  }
})

// 错误处理中间件(参数比普通中间件多一个 err)
app.use((err, req, res, next) => {
  switch (err) {
    case -1001:
      res.status(400).send('用户名或密码不能为空')
      break
    case -1002:
      res.status(401).send('用户名或密码错误')
      break
    case -1003:
      res.status(404).send('用户名不存在')
      break
    default:
      res.status(500).send('服务器内部错误')
  }
})

app.listen(3000)

建议:

  • 错误处理中间件应放在所有路由之后。
  • 可结合日志系统,记录错误详情,便于排查问题。
  • 复杂项目可对错误码、错误信息进行统一管理。
❌
❌