阅读视图

发现新文章,点击刷新页面。

深入 ahooks 3.0 useRequest 源码:插件化架构的精妙设计

深入 ahooks 3.0 useRequest 源码:插件化架构的精妙设计

ahooks 的 useRequest 是一个强大的异步数据管理 Hook,它不仅处理 loading、data、error 等基础状态,还支持轮询、防抖、节流、屏幕聚焦重新请求等高级功能。这一切都建立在一套精妙的插件化架构之上。

一、核心架构:Fetch 类 + Plugin 机制

useRequestImplement.ts 可以看出,核心实现分为三部分:

// 1. 使用 useLatest 保持 service 引用不变
const serviceRef = useLatest(service);

// 2. 使用 useCreation 确保 Fetch 实例只创建一次
const fetchInstance = useCreation(() => {
  const initState = plugins.map((p) => p?.onInit?.(fetchOptions)).filter(Boolean);
  return new Fetch<TData, TParams>(
    serviceRef,
    fetchOptions,
    update,
    Object.assign({}, ...initState),
  );
}, []);

// 3. 运行所有插件钩子
fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));

为什么这样做?

  • useLatest:保持函数引用地址不变,但内部始终指向最新的 service 函数
  • useCreation:类似 useMemo,但保证引用稳定,避免 Fetch 实例重复创建
  • 插件化:将非核心功能(防抖、轮询、缓存等)交给插件处理,核心类保持简洁

二、请求竞态问题:count 计数器方案

当用户快速发起多个请求时,可能出现后发起的请求先返回的情况。ahooks 通过 count 计数器解决:

// Fetch 内部实现(简化版)
class Fetch {
  count = 0;  // 请求计数器

  async run(...params) {
    this.count += 1;
    const currentCount = this.count;  // 记录当前请求的 count

    const result = await this.serviceRef.current(...params);

    // 只有最新的请求结果才会被接受
    if (currentCount !== this.count) return;

    this.setState({ data: result });
  }
}

原理:每次发起请求时 count + 1,请求返回后检查 currentCount === this.count,不匹配则说明已被新请求覆盖,直接丢弃旧结果。

三、组件卸载保护:unmountedRef 标记

避免在组件卸载后执行 setState 导致的内存泄漏警告:

class Fetch {
  unmountedRef = { current: false };

  cancel() {
    this.unmountedRef.current = true;
  }
}

// useRequestImplement.ts 中
useUnmount(() => {
  fetchInstance.cancel();  // 卸载时标记
});

// runAsync 方法中
if (this.unmountedRef.current) return;

通过 unmountedRef 标记位,在请求返回时检查组件是否已卸载,卸载则跳过状态更新。

四、返回方法的引用稳定性:useMemoizedFn

用户可能将 runrefresh 等方法传递给子组件或放入依赖数组,如果引用不稳定会导致无限重渲染:

return {
  run: useMemoizedFn(fetchInstance.run.bind(fetchInstance)),
  refresh: useMemoizedFn(fetchInstance.refresh.bind(fetchInstance)),
  // ...
};

useMemoizedFn 确保无论 Fetch 实例内部如何变化,返回给用户的方法引用始终不变。

五、插件机制的实现

插件通过生命周期钩子介入请求流程,Plugin 类型定义如下:

type Plugin<TData, TParams> = {
  onInit?: (options: Options<TData, TParams>) => any;
  onBefore?: (context: Context<TData, TParams>) => void | Stop;
  onRequest?: (context: Context<TData, TParams>, params: TParams) => void;
  onSuccess?: (data: TData, params: TParams) => void;
  onError?: (error: Error, params: TParams) => void;
  onFinally?: (params: TParams, data?: TData, error?: Error) => void;
  onUnmount?: () => void;
};

runPluginHandler 统一执行插件:

const runPluginHandler = (event: keyof Plugin) => {
  // @ts-ignore
  this.pluginImpls.forEach((impl) => {
    const handler = impl?.[event];
    if (handler) {
      handler(...args);
    }
  });
};

8 个默认插件

ahooks 内置了 8 个插件实现常用功能:

  • useDebouncePlugin:防抖
  • useThrottlePlugin:节流
  • useRetryPlugin:错误重试
  • useCachePlugin:请求缓存
  • usePollingPlugin:轮询
  • useRefreshOnWindowFocusPlugin:聚焦重新请求
  • useAutoRunPlugin:依赖变化自动请求
  • useLoadingDelayPlugin:延迟 loading

每个插件只关注自己的职责,通过生命周期钩子介入请求流程,实现了高度的可扩展性。

总结

ahooks useRequest 的设计精髓在于:

  1. 引用稳定:useLatest、useCreation、useMemoizedFn 三管齐下
  2. 请求安全:count 计数器解决竞态,unmountedRef 防止卸载后更新
  3. 插件化架构:核心类保持简洁,功能扩展通过插件实现

这种设计思想值得在自己的项目中借鉴——核心逻辑稳定可靠,扩展功能灵活可插拔。


参考链接

聊一聊JS异步编程的前世今生

JavaScript 异步编程进化史:从回调地狱到 async/await

前言

如果你问一个前端初学者:"JavaScript 最让你头疼的是什么?",十有八九会听到"异步编程"这个答案。从回调地狱到 Promise 链,再到如今优雅的 async/await,JavaScript 的异步编程经历了一场漫长的进化。今天,我们就来聊聊这段充满血泪的历史。

远古时期:同步的世界(1995-2009)

历史背景

1995 年,Brendan Eich 在 Netscape 公司用 10 天时间创造了 JavaScript(最初叫 LiveScript)。当时的设计初衷非常简单:为浏览器提供简单的页面交互能力,比如表单验证、按钮点击响应等。

那个年代,网页还很简单:

<!-- 1995 年的网页长这样 -->
<form onsubmit="return validateForm()">
  <input type="text" name="username" />
  <button type="submit">提交</button>
</form>

<script>
function validateForm() {
  var username = document.forms[0].username.value;
  if (username === '') {
    alert('用户名不能为空!');
    return false;
  }
  return true;
}
</script>

这个时期的 JavaScript 只需要处理简单的同步操作:

// 计算
var result = 1 + 2;

// DOM 操作
document.getElementById('btn').onclick = function() {
  alert('你点击了按钮');
};

// 表单验证
function validate(value) {
  return value.length > 0;
}

为什么只有同步?

因为当时的网页交互非常简单,不需要复杂的异步操作。即使有网络请求,也是通过表单提交刷新整个页面来完成的。

转折点:AJAX 的诞生

2005 年,Google 推出了 Gmail 和 Google Maps,展示了 AJAX(Asynchronous JavaScript and XML)的强大能力。突然间,网页可以在不刷新的情况下与服务器通信了!

这标志着 JavaScript 正式进入异步时代。

Callback 时期:回调地狱的噩梦(2005-2015)

标志性事件

  • 2005 年:AJAX 技术被广泛应用
  • 2009 年:Node.js 诞生,JavaScript 进入服务端,异步 I/O 成为核心
  • 2010 年:回调函数成为异步编程的主流模式

解决的问题

回调函数让 JavaScript 能够处理异步操作,不会阻塞主线程:

// 发起网络请求
function getUserData(userId, callback) {
  var xhr = new XMLHttpRequest();
  xhr.open('GET', '/api/user/' + userId);
  
  xhr.onload = function() {
    if (xhr.status === 200) {
      callback(null, JSON.parse(xhr.responseText));
    } else {
      callback(new Error('请求失败'));
    }
  };
  
  xhr.send();
}

// 使用
getUserData(123, function(err, user) {
  if (err) {
    console.error(err);
    return;
  }
  console.log('用户信息:', user);
});

新的矛盾:回调地狱(Callback Hell)

当你需要执行多个依赖的异步操作时,代码会变成这样:

// 😱 真实的回调地狱代码
getUserData(123, function(err, user) {
  if (err) {
    console.error('获取用户失败:', err);
    return;
  }
  
  // 获取用户的订单列表
  getOrders(user.id, function(err, orders) {
    if (err) {
      console.error('获取订单失败:', err);
      return;
    }
    
    // 获取第一个订单的详情
    getOrderDetail(orders[0].id, function(err, detail) {
      if (err) {
        console.error('获取订单详情失败:', err);
        return;
      }
      
      // 获取订单中的商品信息
      getProducts(detail.productIds, function(err, products) {
        if (err) {
          console.error('获取商品失败:', err);
          return;
        }
        
        // 计算总价
        calculateTotal(products, function(err, total) {
          if (err) {
            console.error('计算总价失败:', err);
            return;
          }
          
          // 终于可以显示结果了!
          console.log('订单总价:', total);
        });
      });
    });
  });
});

回调地狱的痛点

  1. 代码横向发展:嵌套层级越来越深,形成"金字塔"结构
  2. 错误处理重复:每一层都要写 if (err) 判断
  3. 可读性极差:很难理解代码的执行流程
  4. 难以维护:修改一个环节可能影响整个调用链
  5. 调试困难:错误堆栈信息混乱

再看一个 Node.js 的例子:

// 😱 Node.js 文件操作的回调地狱
fs.readFile('config.json', 'utf8', function(err, config) {
  if (err) throw err;
  
  var parsedConfig = JSON.parse(config);
  
  fs.readFile(parsedConfig.dataFile, 'utf8', function(err, data) {
    if (err) throw err;
    
    var processedData = processData(data);
    
    fs.writeFile('output.json', JSON.stringify(processedData), function(err) {
      if (err) throw err;
      
      fs.readFile('output.json', 'utf8', function(err, result) {
        if (err) throw err;
        
        console.log('处理完成:', result);
      });
    });
  });
});

社区开始意识到:必须找到更好的方式来处理异步代码!

Promise 时期:链式调用的曙光(2012-2017)

标志性事件

  • 2012 年:Promise/A+ 规范发布
  • 2015 年:ES6 正式将 Promise 纳入标准
  • 2015 年:各大浏览器开始原生支持 Promise

解决的问题

Promise 通过链式调用解决了回调地狱的嵌套问题:

// ✅ 使用 Promise 改写
getUserData(123)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetail(orders[0].id))
  .then(detail => getProducts(detail.productIds))
  .then(products => calculateTotal(products))
  .then(total => {
    console.log('订单总价:', total);
  })
  .catch(err => {
    console.error('出错了:', err);
  });

Promise 的优势

  1. 扁平化:不再横向嵌套,而是纵向链式调用
  2. 统一错误处理:一个 .catch() 捕获所有错误
  3. 状态管理:pending、fulfilled、rejected 三种状态清晰
  4. 可组合Promise.all()Promise.race() 等工具方法

新的矛盾:Promise 链的三角形代码

虽然 Promise 解决了回调地狱,但在复杂场景下,仍然会出现新的问题:

// 😱 Promise 的三角形代码
function processUserOrder(userId) {
  return getUserData(userId)
    .then(user => {
      return getOrders(user.id)
        .then(orders => {
          return getOrderDetail(orders[0].id)
            .then(detail => {
              return getProducts(detail.productIds)
                .then(products => {
                  // 这里需要同时访问 user、orders、detail、products
                  return {
                    user: user,
                    orders: orders,
                    detail: detail,
                    products: products
                  };
                });
            });
        });
    })
    .then(result => {
      console.log('用户:', result.user.name);
      console.log('订单数:', result.orders.length);
      console.log('商品:', result.products);
    });
}

问题分析

当你需要在后续步骤中访问前面的变量时,不得不:

  1. 要么嵌套 Promise(又回到了嵌套地狱)
  2. 要么在外层定义变量(污染作用域)
// 😱 方案1:嵌套 Promise(又回到地狱)
getUserData(userId)
  .then(user => {
    return getOrders(user.id)
      .then(orders => {
        return getOrderDetail(orders[0].id)
          .then(detail => {
            // 可以访问 user、orders、detail
            return processData(user, orders, detail);
          });
      });
  });

// 😱 方案2:污染外层作用域
let user, orders, detail;

getUserData(userId)
  .then(u => {
    user = u;
    return getOrders(user.id);
  })
  .then(o => {
    orders = o;
    return getOrderDetail(orders[0].id);
  })
  .then(d => {
    detail = d;
    // 现在可以访问 user、orders、detail
    return processData(user, orders, detail);
  });

其他痛点

// 😱 条件分支变得复杂
getUserData(userId)
  .then(user => {
    if (user.isVip) {
      return getVipOrders(user.id)
        .then(orders => {
          return { user, orders, isVip: true };
        });
    } else {
      return getNormalOrders(user.id)
        .then(orders => {
          return { user, orders, isVip: false };
        });
    }
  })
  .then(result => {
    // 处理结果...
  });

// 😱 循环中的 Promise
function processItems(items) {
  let promise = Promise.resolve();
  
  items.forEach(item => {
    promise = promise.then(() => {
      return processItem(item);
    });
  });
  
  return promise;
}

社区再次呼唤:能不能像写同步代码一样写异步?

Async/Await 时期:异步编程的终极形态(2017-至今)

标志性事件

  • 2017 年:ES8(ES2017)正式引入 async/await
  • 2017 年:Node.js 7.6+ 原生支持 async/await
  • 2018 年:主流浏览器全面支持

解决的问题

async/await 让异步代码看起来像同步代码:

// ✅ 使用 async/await 改写
async function processUserOrder(userId) {
  try {
    const user = await getUserData(userId);
    const orders = await getOrders(user.id);
    const detail = await getOrderDetail(orders[0].id);
    const products = await getProducts(detail.productIds);
    const total = await calculateTotal(products);
    
    console.log('订单总价:', total);
    
    // 可以轻松访问所有变量
    console.log('用户:', user.name);
    console.log('订单数:', orders.length);
    console.log('商品:', products);
    
  } catch (err) {
    console.error('出错了:', err);
  }
}

对比三个时代的代码

// 😱 Callback 版本
getUserData(123, function(err, user) {
  if (err) return console.error(err);
  
  getOrders(user.id, function(err, orders) {
    if (err) return console.error(err);
    
    console.log('用户:', user.name);
    console.log('订单数:', orders.length);
  });
});

// 😐 Promise 版本
let user;
getUserData(123)
  .then(u => {
    user = u;
    return getOrders(user.id);
  })
  .then(orders => {
    console.log('用户:', user.name);
    console.log('订单数:', orders.length);
  })
  .catch(err => console.error(err));

// ✅ Async/Await 版本
async function process() {
  try {
    const user = await getUserData(123);
    const orders = await getOrders(user.id);
    
    console.log('用户:', user.name);
    console.log('订单数:', orders.length);
  } catch (err) {
    console.error(err);
  }
}

Async/Await 的优势

1. 代码可读性极高
// ✅ 像写同步代码一样清晰
async function checkout() {
  const cart = await getCart();
  const address = await getAddress();
  const payment = await processPayment(cart.total);
  const order = await createOrder(cart, address, payment);
  
  return order;
}
2. 错误处理更自然
// ✅ 使用熟悉的 try-catch
async function fetchData() {
  try {
    const data = await fetch('/api/data');
    const json = await data.json();
    return json;
  } catch (err) {
    console.error('请求失败:', err);
    throw err;
  }
}
3. 条件分支更简洁
// ✅ 条件判断很自然
async function processUser(userId) {
  const user = await getUserData(userId);
  
  if (user.isVip) {
    const vipOrders = await getVipOrders(user.id);
    return processVipOrders(vipOrders);
  } else {
    const normalOrders = await getNormalOrders(user.id);
    return processNormalOrders(normalOrders);
  }
}
4. 循环处理更直观
// ✅ 顺序处理
async function processItems(items) {
  for (const item of items) {
    await processItem(item);
  }
}

// ✅ 并行处理
async function processItemsParallel(items) {
  await Promise.all(items.map(item => processItem(item)));
}
5. 调试体验更好
// ✅ 可以直接打断点,查看变量
async function debug() {
  const user = await getUserData(123);
  debugger; // 可以在这里查看 user
  
  const orders = await getOrders(user.id);
  debugger; // 可以在这里查看 orders
  
  return orders;
}

实战案例

案例 1:文件处理
// Callback 版本 😱
fs.readFile('input.txt', 'utf8', function(err, data) {
  if (err) throw err;
  
  const processed = processData(data);
  
  fs.writeFile('output.txt', processed, function(err) {
    if (err) throw err;
    
    fs.readFile('output.txt', 'utf8', function(err, result) {
      if (err) throw err;
      console.log('完成:', result);
    });
  });
});

// Async/Await 版本 ✅
async function processFile() {
  try {
    const data = await fs.promises.readFile('input.txt', 'utf8');
    const processed = processData(data);
    await fs.promises.writeFile('output.txt', processed);
    const result = await fs.promises.readFile('output.txt', 'utf8');
    console.log('完成:', result);
  } catch (err) {
    console.error('出错:', err);
  }
}
案例 2:并发请求
// Promise 版本 😐
Promise.all([
  fetch('/api/user'),
  fetch('/api/orders'),
  fetch('/api/products')
])
  .then(responses => {
    return Promise.all(responses.map(r => r.json()));
  })
  .then(([user, orders, products]) => {
    console.log(user, orders, products);
  });

// Async/Await 版本 ✅
async function fetchAll() {
  const [user, orders, products] = await Promise.all([
    fetch('/api/user').then(r => r.json()),
    fetch('/api/orders').then(r => r.json()),
    fetch('/api/products').then(r => r.json())
  ]);
  
  console.log(user, orders, products);
}
案例 3:错误重试
// ✅ 实现带重试的请求
async function fetchWithRetry(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url);
      return await response.json();
    } catch (err) {
      if (i === maxRetries - 1) throw err;
      console.log(`重试 ${i + 1}/${maxRetries}`);
      await sleep(1000 * (i + 1)); // 指数退避
    }
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

常见陷阱与最佳实践

陷阱 1:忘记 await
// ❌ 错误:忘记 await
async function bad() {
  const data = fetchData(); // 返回 Promise,不是数据!
  console.log(data); // Promise { <pending> }
}

// ✅ 正确
async function good() {
  const data = await fetchData();
  console.log(data); // 实际数据
}
陷阱 2:串行执行导致性能问题
// ❌ 错误:串行执行,耗时 3 秒
async function slow() {
  const user = await fetchUser();      // 1 秒
  const orders = await fetchOrders();  // 1 秒
  const products = await fetchProducts(); // 1 秒
  return { user, orders, products };
}

// ✅ 正确:并行执行,耗时 1 秒
async function fast() {
  const [user, orders, products] = await Promise.all([
    fetchUser(),
    fetchOrders(),
    fetchProducts()
  ]);
  return { user, orders, products };
}
陷阱 3:循环中的 await
// ❌ 错误:串行处理,很慢
async function processItemsSlow(items) {
  const results = [];
  for (const item of items) {
    results.push(await processItem(item)); // 一个一个处理
  }
  return results;
}

// ✅ 正确:并行处理,快速
async function processItemsFast(items) {
  return await Promise.all(items.map(item => processItem(item)));
}
陷阱 4:错误处理不当
// ❌ 错误:错误被吞掉
async function bad() {
  await fetchData(); // 如果出错,错误会被忽略
}

// ✅ 正确:捕获错误
async function good() {
  try {
    await fetchData();
  } catch (err) {
    console.error('出错:', err);
    throw err; // 或者处理错误
  }
}

进化史总结

时期 时间 特点 优点 缺点
同步时期 1995-2005 只有同步代码 简单直观 无法处理异步
Callback 2005-2015 回调函数 能处理异步 回调地狱、错误处理繁琐
Promise 2012-2017 链式调用 扁平化、统一错误处理 三角形代码、变量作用域问题
Async/Await 2017-至今 同步风格写异步 可读性强、易调试、易维护 需要注意性能陷阱

未来展望

虽然 async/await 已经很完美,但 JavaScript 的异步编程仍在进化:

1. Top-level await(ES2022)

// ✅ 模块顶层直接使用 await
const data = await fetch('/api/data');
export default data;

2. AsyncIterator 和 for-await-of

// ✅ 异步迭代器
async function* generateData() {
  for (let i = 0; i < 10; i++) {
    await sleep(100);
    yield i;
  }
}

for await (const num of generateData()) {
  console.log(num);
}

3. Promise.allSettled / Promise.any

// ✅ 等待所有 Promise 完成(无论成功失败)
const results = await Promise.allSettled([
  fetch('/api/1'),
  fetch('/api/2'),
  fetch('/api/3')
]);

// ✅ 返回第一个成功的 Promise
const fastest = await Promise.any([
  fetch('/api/1'),
  fetch('/api/2'),
  fetch('/api/3')
]);

总结

从回调地狱到 async/await,JavaScript 的异步编程经历了三次重大进化:

  1. Callback:解决了异步问题,但带来了回调地狱
  2. Promise:解决了回调地狱,但带来了三角形代码
  3. Async/Await:让异步代码像同步代码一样优雅

如今,async/await 已经成为 JavaScript 异步编程的事实标准。它不仅解决了前辈们的问题,还提供了极佳的开发体验。

最佳实践建议

  • ✅ 优先使用 async/await
  • ✅ 注意并行执行优化性能
  • ✅ 使用 try-catch 处理错误
  • ✅ 理解 Promise 的底层原理
  • ✅ 善用 Promise.all/race/allSettled/any

异步编程的进化史告诉我们:好的语言特性不是一蹴而就的,而是在不断解决实际问题中逐步完善的


如果这篇文章对你有帮助,欢迎点赞收藏!有任何问题欢迎在评论区讨论 🎉

参考资料

五个chrome ! 我再也不用切账号了

告别多账号切换烦恼:Chrome 多实例配置管理方案

前言

相信做过自动化测试、爬虫开发或者需要管理多个平台账号的同学,都遇到过这样的痛点:

  • 🔄 频繁切换账号:测试不同账号的功能,手动登录登出效率低下
  • 🚫 无痕模式不给力:每次都要重新登录,Cookie、LocalStorage 全丢失
  • 🤯 脚本跑不起来:自动化脚本需要稳定的登录态,手动切换根本不现实
  • 📦 配置难管理:多个 Chrome 实例的配置散落各处,维护成本高

今天分享一个轻量级的解决方案:Chrome 多实例配置管理器,让你优雅地管理多个独立的 Chrome 实例。

核心思路

Chrome 提供了两个关键参数:

--user-data-dir=/path/to/profile    # 独立的用户数据目录
--remote-debugging-port=9222         # 远程调试端口

通过为每个账号分配独立的用户数据目录和调试端口,我们可以:

  1. 完全隔离:每个实例拥有独立的 Cookie、LocalStorage、插件等
  2. 持久化登录:关闭浏览器后,下次启动自动恢复登录态
  3. 并行运行:多个实例可以同时运行,互不干扰
  4. 脚本友好:通过调试端口,可以用 Puppeteer/Playwright 控制浏览器

效果也非常棒

image.png

每个实例都是完全隔开

快速上手

1. 创建配置

const { createConfig } = require('./chromeToolConfig')

// 创建抖音专用配置
const douyinConfig = await createConfig({
  name: '抖音账号1',
  config: {
    userDataDir: './chrome-profiles/douyin-1',
    remoteDebuggingPort: 9222
  }
})

// 创建微博专用配置
const weiboConfig = await createConfig({
  name: '微博账号1',
  config: {
    userDataDir: './chrome-profiles/weibo-1',
    remoteDebuggingPort: 9223
  }
})

// 创建抖音和微博第一个小号配置
const config2 = await createConfig({
  name: '小号A',
  config: {
    userDataDir: './chrome-profiles/douyin-2',
    remoteDebuggingPort: 9224
  }
})

// 创建抖音和微博第二个小号配置
const config3 = await createConfig({
  name: '小号B',
  config: {
    userDataDir: './chrome-profiles/douyin-2',
    remoteDebuggingPort: 9225
  }
})

2. 启动 Chrome

提供了三种启动模式,满足不同场景需求:

# 默认模式:如果已运行则提示
node chromeToolConfig/launch.js 1

# 强制重启:杀死现有进程并重启
node chromeToolConfig/launch.js 1 -hard

# 复用模式:将现有窗口置顶
node chromeToolConfig/launch.js 1 -soft

# 启动时打开指定网址
node chromeToolConfig/launch.js 1 https://www.douyin.com

模式对比

模式 场景 行为
-normal 日常使用 已运行时提示用户选择
-hard 脚本自动化 强制重启,确保干净环境
-soft 快速切换 复用进程,秒级响应

3. 管理账号链接

为每个配置关联平台账号信息:

const { addLink } = require('./chromeToolConfig')

// 为配置添加抖音账号信息
await addLink('chrome_config-1', 'douyin', {
  id: 'user_123456',
  name: '我的抖音账号',
  extra: {
    粉丝数: '10万',
    备注: '主账号'
  }
})

// 添加微博账号
await addLink('chrome_config-1', 'weibo', {
  id: 'weibo_789',
  name: '我的微博'
})

4. 书签同步

将一个配置的账号信息同步到另一个配置:

const { syncBookmarks } = require('./chromeToolConfig')

// 合并模式:保留目标配置的现有账号
await syncBookmarks('1', '2', { merge: true })

// 覆盖模式:完全替换目标配置的账号
await syncBookmarks('1', '2', { merge: false })

// 只同步指定平台
await syncBookmarks('1', '2', { 
  platforms: ['douyin', 'weibo'] 
})

实战场景

场景 1:自动化测试

const puppeteer = require('puppeteer')
const { launchChrome } = require('./chromeToolConfig')

async function runTest() {
  // 启动配置 1 的 Chrome
  const result = await launchChrome('1')
  
  // 连接到已启动的 Chrome
  const browser = await puppeteer.connect({
    browserURL: `http://localhost:${result.port}`
  })
  
  const page = await browser.newPage()
  await page.goto('https://www.douyin.com')
  
  // 执行测试...
  // 登录态已自动恢复,无需重新登录
}

场景 2:多账号并行操作

# 终端 1:启动账号 1
node chromeToolConfig/launch.js 1 https://www.douyin.com

# 终端 2:启动账号 2
node chromeToolConfig/launch.js 2 https://www.douyin.com

# 终端 3:启动账号 3
node chromeToolConfig/launch.js 3 https://www.douyin.com

三个窗口同时运行,互不干扰,可以同时进行不同账号的操作。

场景 3:快速切换账号

# 工作时使用账号 1
node chromeToolConfig/launch.js 1 -soft

# 需要切换到账号 2
node chromeToolConfig/launch.js 2 -soft

# 回到账号 1
node chromeToolConfig/launch.js 1 -soft

使用 -soft 模式,秒级切换,窗口自动置顶。

核心实现

1. 配置管理

// api/config.js
async function createConfig(data) {
  // 验证数据
  validateConfig(data)
  
  // 检查端口和目录唯一性
  await checkUniqueness(data.config)
  
  // 生成 ID
  const id = generateId('chrome_config')
  
  // 保存配置
  const config = {
    id,
    name: data.name,
    config: data.config,
    links: {},
    createdAt: new Date().toISOString()
  }
  
  await db.save('configs', id, config)
  return config
}

2. Chrome 启动

// api/chrome.js
async function launchChrome(id, options = {}) {
  const config = await getConfig(id)
  const userDataDir = path.resolve(config.config.userDataDir)
  
  // 确保目录存在
  await fs.ensureDir(userDataDir)
  
  // 构建启动参数
  const args = [
    `--user-data-dir=${userDataDir}`,
    `--remote-debugging-port=${config.config.remoteDebuggingPort}`
  ]
  
  if (options.url) {
    args.push(options.url)
  }
  
  // 启动 Chrome
  const chromePath = getChromePath()
  const chromeProcess = spawn(chromePath, args, {
    detached: true,
    stdio: 'ignore'
  })
  
  chromeProcess.unref()
  
  return {
    pid: chromeProcess.pid,
    port: config.config.remoteDebuggingPort
  }
}

3. 进程管理

// 检查是否运行
async function isChromRunning(id) {
  const config = await getConfig(id)
  const port = config.config.remoteDebuggingPort
  
  return new Promise((resolve) => {
    const req = http.get(
      `http://localhost:${port}/json/version`,
      (res) => resolve(res.statusCode === 200)
    )
    
    req.on('error', () => resolve(false))
    req.setTimeout(1000, () => {
      req.destroy()
      resolve(false)
    })
  })
}

// 杀死进程
async function killChrome(id) {
  const config = await getConfig(id)
  const port = config.config.remoteDebuggingPort
  
  // 通过端口查找并杀死进程
  return await killChromeByPort(port)
}

项目结构

chromeToolConfig/
├── api/                    # API 层
│   ├── config.js          # 配置管理
│   ├── link.js            # 账号链接管理
│   ├── chrome.js          # Chrome 启动管理
│   └── validator.js       # 数据验证
├── database/              # 数据层
│   ├── db.js              # 数据库操作
│   ├── configs.json       # 配置存储
│   └── todos.json         # 待办事项
├── utils/                 # 工具函数
│   ├── index.js           # 通用工具
│   └── sync.js            # 书签同步
├── launch.js              # 命令行启动工具
└── index.js               # 主入口

设计亮点

1. ID 命名规范

采用统一的命名规则:

  • 配置 ID:chrome_config-1
  • 待办 ID:todo-1

规则

  • 同一含义用 _ 连接(如 chrome_config
  • 不同含义用 - 连接(如 chrome_config-1

2. 自动 ID 补全

支持简写,自动补全前缀:

// 输入 "1",自动转换为 "chrome_config-1"
await getConfig('1')
await launchChrome('1')
await syncBookmarks('1', '2')

3. 数据验证

使用 Joi 进行严格的数据验证:

const configSchema = Joi.object({
  name: Joi.string().required(),
  config: Joi.object({
    userDataDir: Joi.string().required(),
    remoteDebuggingPort: Joi.number().integer().min(1024).max(65535).required(),
    headless: Joi.boolean(),
    windowSize: Joi.string().pattern(/^\d+,\d+$/)
  }).required()
})

4. 跨平台支持

自动检测操作系统,使用对应的 Chrome 路径:

function getChromePath() {
  const platform = process.platform
  
  if (platform === 'darwin') {
    return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
  } else if (platform === 'win32') {
    return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  } else {
    return 'google-chrome'
  }
}

扩展能力

1. 与 Puppeteer 集成

const puppeteer = require('puppeteer')
const { launchChrome } = require('./chromeToolConfig')

async function connectToChrome(configId) {
  const result = await launchChrome(configId)
  
  const browser = await puppeteer.connect({
    browserURL: `http://localhost:${result.port}`
  })
  
  return browser
}

2. 与 Playwright 集成

const { chromium } = require('playwright')
const { launchChrome } = require('./chromeToolConfig')

async function connectToChrome(configId) {
  const result = await launchChrome(configId)
  
  const browser = await chromium.connectOverCDP(
    `http://localhost:${result.port}`
  )
  
  return browser
}

3. 定时任务

const cron = require('node-cron')

// 每天凌晨 2 点重启所有 Chrome 实例
cron.schedule('0 2 * * *', async () => {
  const configs = await getAllConfigs()
  
  for (const config of configs) {
    await killChrome(config.id)
    await new Promise(resolve => setTimeout(resolve, 1000))
    await launchChrome(config.id)
  }
})

最佳实践

1. 端口分配

建议为不同用途的配置分配不同的端口段:

  • 9222-9229:开发环境
  • 9230-9239:测试环境
  • 9240-9249:生产环境

2. 目录管理

使用有意义的目录名:

await createConfig({
  name: '抖音-主账号',
  config: {
    userDataDir: './chrome-profiles/douyin/main',
    remoteDebuggingPort: 9222
  }
})

await createConfig({
  name: '抖音-测试账号',
  config: {
    userDataDir: './chrome-profiles/douyin/test',
    remoteDebuggingPort: 9223
  }
})

3. 定期清理

定期清理不用的配置和用户数据目录:

const { deleteConfig } = require('./chromeToolConfig')
const fs = require('fs-extra')

async function cleanup(configId) {
  const config = await getConfig(configId)
  
  // 删除配置
  await deleteConfig(configId)
  
  // 删除用户数据目录
  await fs.remove(config.config.userDataDir)
}

4. 错误处理

async function safelaunchChrome(configId) {
  try {
    // 检查是否已运行
    const isRunning = await isChromRunning(configId)
    
    if (isRunning) {
      console.log('Chrome 已在运行,使用 -hard 模式重启')
      await killChrome(configId)
      await new Promise(resolve => setTimeout(resolve, 1000))
    }
    
    return await launchChrome(configId)
  } catch (error) {
    console.error('启动失败:', error.message)
    throw error
  }
}

性能优化

1. 延迟加载

只在需要时才启动 Chrome:

class ChromeManager {
  constructor() {
    this.instances = new Map()
  }
  
  async getInstance(configId) {
    if (!this.instances.has(configId)) {
      const result = await launchChrome(configId)
      this.instances.set(configId, result)
    }
    
    return this.instances.get(configId)
  }
}

2. 连接池

复用已启动的实例:

class ChromePool {
  constructor(maxSize = 5) {
    this.pool = []
    this.maxSize = maxSize
  }
  
  async acquire(configId) {
    // 查找空闲实例
    let instance = this.pool.find(i => i.configId === configId && !i.busy)
    
    if (!instance) {
      // 创建新实例
      if (this.pool.length >= this.maxSize) {
        throw new Error('Pool is full')
      }
      
      const result = await launchChrome(configId)
      instance = { ...result, busy: false }
      this.pool.push(instance)
    }
    
    instance.busy = true
    return instance
  }
  
  release(instance) {
    instance.busy = false
  }
}

总结

Chrome 多实例配置管理器通过以下特性,彻底解决了多账号管理的痛点:

完全隔离:每个账号独立的用户数据目录
持久化登录:关闭浏览器后自动恢复登录态
灵活启动:三种模式满足不同场景需求
脚本友好:通过调试端口轻松集成自动化工具
配置管理:统一管理所有配置和账号信息
跨平台:支持 macOS、Windows、Linux

无论是自动化测试、爬虫开发,还是日常的多账号管理,这个方案都能大幅提升效率。

参考资料


如果这篇文章对你有帮助,欢迎点赞收藏!有任何问题欢迎在评论区讨论 🎉

❌