Playwright拦截与修改WebSocket通信:从原理到实战 1. 项目概述为什么需要拦截WebSocket如果你在用Playwright做自动化测试或者数据抓取遇到一个全是动态内容的现代Web应用比如一个实时更新的股票看板、一个在线协作的白板工具或者一个聊天应用你可能会发现传统的页面监听和网络请求拦截page.on(‘request’)完全失效了。页面内容在你眼皮子底下“唰”地一下就变了但你却抓不到任何HTTP请求的痕迹。这时候十有八九是WebSocket在“搞鬼”。WebSocket作为一种全双工通信协议早已不是新鲜事物。它让浏览器和服务器之间可以建立一条持久连接实现数据的实时、双向流动。这对于需要高频、低延迟数据交换的场景是福音但对于我们这些搞自动化的来说就成了一个必须攻克的“黑盒”。你不能像拦截HTTP请求那样简单地修改请求头或响应体。WebSocket通信建立后就是一条持续的数据流传统的网络拦截器对它束手无策。所以“拦截和修改WebSocket通信”这个需求就变得非常具体且迫切。你可能需要测试数据Mock在自动化测试中模拟服务器发送的特定消息来验证前端在不同数据状态下的UI表现而不需要依赖不稳定的真实后端。篡改请求参数修改客户端通过WebSocket发送给服务器的数据用于测试边界条件或异常情况。监听与分析纯粹地监听所有进出的WebSocket消息用于调试、分析应用的数据流或者抓取实时数据。性能与安全测试模拟网络延迟、发送畸形数据包以测试应用的健壮性。Playwright作为新一代的浏览器自动化工具其强大之处就在于它提供了底层的、对浏览器协议CDP的访问能力。正是基于此我们才能实现WebSocket这种“高级”操作的拦截与修改。接下来我们就深入拆解如何利用Playwright的这个能力把WebSocket通信从“黑盒”变成“透明盒”。2. 核心原理Playwright如何“触及”WebSocket在动手写代码之前我们必须先搞清楚Playwright是怎么做到这件事的。这能帮你理解后续代码的每一个步骤并在遇到问题时知道该往哪个方向排查。Playwright本身并不直接实现一个WebSocket协议的解析器和修改器。它的核心能力是作为浏览器与测试脚本之间的桥梁。当我们启动一个浏览器上下文browserContext时Playwright会通过Chrome DevTools ProtocolCDP与浏览器内核建立连接。CDP是Chrome/Chromium浏览器暴露给开发者工具的一套底层调试协议功能极其强大几乎能控制浏览器的所有行为包括网络、DOM、性能、缓存等等。WebSocket通信的拦截正是通过CDP中的Network领域实现的。具体来说关键的两个CDP命令是Network.enable启用网络跟踪。只有启用后浏览器才会将网络事件包括WebSocket通过CDP发送给客户端即我们的Playwright脚本。Network.setCacheDisabled(可选但推荐)禁用缓存确保我们能捕获到所有网络活动。当WebSocket连接建立和收发消息时CDP会派发特定的事件Network.webSocketCreated当一个WebSocket连接被创建时触发事件中包含了WebSocket的唯一标识符requestId。Network.webSocketFrameSent当一帧数据一个消息从浏览器发送到服务器时触发。Network.webSocketFrameReceived当一帧数据从服务器发送到浏览器时触发。Playwright的page.on(‘websocket’)监听器本质上就是帮我们订阅了这些CDP事件并进行了友好的封装。但是这个监听器默认只提供了“只读”能力——你能知道消息来了也能拿到消息内容但你无法修改它。要实现“修改”我们就必须绕过这层封装直接与CDP对话。这就是我们方案的核心通过Playwright提供的CDP会话CDPSession接口直接发送原始的CDP命令来拦截并有可能修改WebSocket帧。注意此方法高度依赖CDP因此主要适用于Chromium系浏览器Chrome, Edge, Chromium。对于Firefox和WebKitCDP的支持程度或命令名称可能不同需要额外处理本文重点讨论最通用的Chromium方案。3. 环境准备与基础监听在开始拦截修改之前我们先搭建一个最基础的WebSocket监听环境。这是所有后续操作的地基。3.1 项目初始化与依赖安装首先创建一个新的项目目录并初始化。这里以Node.js环境为例Python版本的思路完全一致只是API调用方式不同。mkdir playwright-websocket-demo cd playwright-websocket-demo npm init -y npm install playwright确保你的Playwright版本在1.40以上这个版本附近的CDP相关API比较稳定。同时安装浏览器内核npx playwright install chromium3.2 建立基础监听脚本我们来写一个脚本它能够打开一个包含WebSocket的测试页面并打印出所有WebSocket连接和消息。我们使用一个经典的WebSocket测试网站wss://echo.websocket.org。// basic_listen.js const { chromium } require(playwright); (async () { const browser await chromium.launch({ headless: false }); // 非无头模式方便观察 const context await browser.newContext(); const page await context.newPage(); // 核心监听页面上的websocket事件 page.on(websocket, ws { console.log(WebSocket opened: ${ws.url()}); ws.on(framesent, event { console.log([Client - Server]:, event.payload); }); ws.on(framereceived, event { console.log([Server - Client]:, event.payload); }); ws.on(close, () console.log(WebSocket Closed)); }); // 导航到一个会建立WebSocket连接的页面 // 这里我们直接执行一段脚本创建一个到测试服务器的连接 await page.goto(about:blank); await page.evaluate(() { const ws new WebSocket(wss://echo.websocket.org); ws.onopen () { console.log(Connected (from browser)); ws.send(Hello from Browser!); ws.send(JSON.stringify({ type: test, data: [1, 2, 3] })); }; ws.onmessage (event) { console.log(Received (from browser):, event.data); }; }); // 等待一段时间查看输出 await page.waitForTimeout(5000); await browser.close(); })();运行这个脚本node basic_listen.js。你会在控制台看到类似以下的输出WebSocket opened: wss://echo.websocket.org/ [Client - Server]: Hello from Browser! [Server - Client]: Hello from Browser! [Client - Server]: {type:test,data:[1,2,3]} [Server - Client]: {type:test,data:[1,2,3]}实操心得page.on(‘websocket’)监听的是整个页面生命周期内创建的所有WebSocket连接。一个页面可能有多个连接每个都会触发此事件。ws.url()可以帮助你区分不同的WebSocket连接这在处理多个连接时至关重要。监听器必须在WebSocket连接建立之前就挂载好。通常我们在page.goto()之前或之后立即挂载。这个阶段我们只是“看客”消息已经原封不动地发送和接收了。接下来我们要成为“导演”。4. 进阶实现拦截与修改WebSocket消息基础监听只能看不能改。现在我们来进入核心环节拦截并修改数据。这需要用到CDPSession。4.1 获取CDP会话并启用网络追踪每个浏览器上下文BrowserContext都可以创建一个到其背后浏览器Tab页的CDP会话。// intercept_modify.js const { chromium } require(playwright); (async () { const browser await chromium.launch({ headless: false }); const context await browser.newContext(); const page await context.newPage(); // 1. 获取当前页面的CDP会话 const cdpSession await context.newCDPSession(page); // 2. 启用网络域Network domain这是接收网络事件的前提 await cdpSession.send(Network.enable); // 可选禁用缓存确保流量捕获完整 await cdpSession.send(Network.setCacheDisabled, { cacheDisabled: true }); // 3. 监听CDP的WebSocket事件 cdpSession.on(Network.webSocketCreated, ({ requestId, url }) { console.log(CDP: WebSocket创建ID: ${requestId}, URL: ${url}); }); cdpSession.on(Network.webSocketFrameSent, async ({ requestId, response }) { // response 包含 timestamp 和 payload (base64编码的原始数据) const originalPayload Buffer.from(response.payloadData, base64).toString(utf-8); console.log([CDP 拦截到发送] ID:${requestId}, 原始数据: ${originalPayload}); // TODO: 在这里实现修改逻辑 }); cdpSession.on(Network.webSocketFrameReceived, async ({ requestId, response }) { const originalPayload Buffer.from(response.payloadData, base64).toString(utf-8); console.log([CDP 拦截到接收] ID:${requestId}, 原始数据: ${originalPayload}); // TODO: 在这里实现修改逻辑 }); // 4. 同样创建一个测试WebSocket连接 await page.goto(about:blank); await page.evaluate(() { const ws new WebSocket(wss://echo.websocket.org); ws.onopen () { console.log(Connected (from browser)); ws.send(Message to be intercepted); }; ws.onmessage (event) { console.log(Browser received:, event.data); }; }); await page.waitForTimeout(3000); await browser.close(); })();运行这个脚本你会看到来自CDP层的更原始的事件日志。但消息依然没有被修改。因为我们现在只是监听还没有“拦截”并“替换”数据流。4.2 实现消息修改拦截发送帧修改的核心在于当我们监听到一个帧webSocketFrameSent或webSocketFrameReceived时我们需要阻止原帧的发送/接收并用我们修改后的新帧替换它。然而CDP的Network领域并没有直接提供一个“修改并继续”的命令。标准的拦截修改流程通常用于HTTP请求WebSocket的修改更为棘手。一种可行的方法是在消息到达浏览器网络栈之前通过CDP模拟发送一个我们自定义的帧但这需要精确的时序控制且容易导致连接状态混乱。一个更稳健、在实践中被广泛采用的模式是“监听 模拟”。即我们不直接修改原始连接上的数据流而是监听原始连接上的关键消息。一旦发现需要修改的消息就记录下其内容。在测试脚本层面断开或忽略原始连接然后创建一个由测试脚本完全控制的模拟WebSocket连接Mock来与页面交互。但对于纯粹的“修改”需求Playwright社区和CDP本身的支持有限。一个更底层的思路是使用Fetch领域来拦截WebSocket的Upgrade请求但这只能修改握手阶段的HTTP头无法修改建立后的数据帧。因此对于“修改WebSocket通信内容”这个需求最实用的Playwright方案其实是“拦截并替换整个WebSocket对象”。这需要在页面上下文中注入脚本覆盖原生的WebSocket构造函数。下面是一个结合了CDP监听和页面脚本注入的混合方案示例它能够修改从客户端发送出去的消息// intercept_and_modify_hybrid.js const { chromium } require(playwright); (async () { const browser await chromium.launch({ headless: false }); const context await browser.newContext(); const page await context.newPage(); // --- 第一部分CDP监听用于记录和诊断 --- const cdpSession await context.newCDPSession(page); await cdpSession.send(Network.enable); let targetWebSocketId null; cdpSession.on(Network.webSocketCreated, ({ requestId, url }) { console.log(追踪WebSocket: ${url}, ID: ${requestId}); if (url.includes(echo.websocket.org)) { // 定位目标连接 targetWebSocketId requestId; } }); // --- 第二部分注入脚本覆盖WebSocket行为 --- await page.addInitScript(() { const originalWebSocket window.WebSocket; window.WebSocket function(...args) { const wsUrl args[0]; console.log([注入脚本] 尝试创建WebSocket连接到: ${wsUrl}); const socket new originalWebSocket(...args); // 劫持send方法 const originalSend socket.send.bind(socket); socket.send function(data) { console.log([注入脚本] 拦截到发送数据: ${data}); // **修改逻辑在这里** let modifiedData data; if (typeof data string) { // 例如将所有发送的字符串消息加上前缀 modifiedData [MODIFIED] ${data}; console.log([注入脚本] 修改为: ${modifiedData}); } // 调用原始的send方法发送修改后的数据 return originalSend(modifiedData); }; // 你也可以劫持onmessage来修改接收到的数据 // socket.addEventListener(message, (event) { ... }); return socket; }; }); // --- 第三部分页面逻辑 --- await page.goto(about:blank); await page.evaluate(() { const ws new WebSocket(wss://echo.websocket.org); ws.onopen () { console.log(Connected (from browser)); ws.send(Hello Original); // 这行发送的数据会被修改 ws.send(JSON.stringify({ cmd: ping })); // 这行也会被修改 }; ws.onmessage (event) { // 服务器回显的是我们修改后的消息 console.log(Browser received:, event.data); // 预期输出: [MODIFIED] Hello Original }; }); await page.waitForTimeout(4000); await browser.close(); })();关键点解析CDP部分主要用于辅助我们确认连接已建立并获取连接ID便于在复杂场景下进行精准管理。注入脚本部分这是实现修改的核心。page.addInitScript在页面加载任何框架之前执行它覆盖了全局的WebSocket构造函数。任何在页面中执行的new WebSocket()都会使用我们自定义的版本。修改时机我们劫持了send方法。当页面代码调用ws.send(data)时实际上调用的是我们包装后的函数。我们有机会在这里对data进行任何修改字符串替换、JSON解析修改、条件判断等然后再调用原始的send方法将修改后的数据发送出去。局限性这种方法修改的是从浏览器发往服务器的消息。对于服务器发往浏览器的消息我们需要劫持onmessage事件或者addEventListener(‘message’)原理类似但要注意避免循环触发。重要注意事项这种注入脚本的方式其生效范围仅限于通过该page对象执行的页面JavaScript。如果WebSocket连接是由页面内嵌的iframe或者动态加载的脚本创建的并且该iframe与主页面不同源则注入的脚本可能无法生效。这是浏览器安全策略同源策略的限制。5. 实战场景与代码示例理解了核心原理和基础方法后我们来看几个具体的实战场景并给出更完整的代码示例。5.1 场景一自动化测试中的WebSocket消息Mock在测试一个实时通知功能时你需要模拟服务器发送一条特定的通知消息来验证前端UI是否正确渲染。// scenario_mock_server_message.js const { chromium } require(playwright); (async () { const browser await chromium.launch({ headless: true }); // 测试通常用无头模式 const context await browser.newContext(); const page await context.newPage(); // 注入脚本用于模拟服务器消息 await page.addInitScript(() { const originalWebSocket window.WebSocket; window.WebSocket function(...args) { const socket new originalWebSocket(...args); const originalAddEventListener socket.addEventListener.bind(socket); // 劫持message事件监听器 const messageHandlers []; socket.addEventListener function(type, listener, options) { if (type message) { messageHandlers.push({ listener, options }); // 我们仍然调用原始的但会包装listener return originalAddEventListener(type, function(event) { // 这里是关键当真实消息到达时我们先检查是否要模拟 console.log(Real message arrived:, event.data); // 为了示例我们直接触发一个模拟消息 // 实际测试中你可能需要根据条件判断 setTimeout(() { const mockEvent new MessageEvent(message, { data: JSON.stringify({ type: notification, content: This is a MOCK server message!, urgent: true }) }); listener.call(this, mockEvent); }, 1000); // 延迟1秒模拟网络延迟 }, options); } return originalAddEventListener(type, listener, options); }; // 提供一个测试钩子让Playwright脚本可以主动触发模拟消息 socket._triggerMockMessage (mockData) { const mockEvent new MessageEvent(message, { data: JSON.stringify(mockData) }); messageHandlers.forEach(({ listener }) { try { listener.call(socket, mockEvent); } catch (e) {} }); }; window._lastWebSocket socket; // 暴露给evalute使用 return socket; }; }); await page.goto(https://your-app-under-test.com/notifications); // 等待页面自己的WebSocket连接建立 await page.waitForTimeout(2000); // 通过evaluate调用我们暴露的钩子主动触发一条模拟消息 await page.evaluate(() { if (window._lastWebSocket window._lastWebSocket._triggerMockMessage) { window._lastWebSocket._triggerMockMessage({ type: notification, content: 【测试】您的订单已发货, timestamp: new Date().toISOString() }); } }); // 接下来你可以使用Playwright的断言来检查页面上是否出现了预期的UI元素 // 例如await expect(page.locator(.notification-list)).toContainText(您的订单已发货); await page.waitForTimeout(2000); console.log(Mock message sent. UI should update.); await browser.close(); })();这个示例展示了如何在测试中主动控制模拟消息的发送而不是被动等待或修改真实流量。5.2 场景二修改WebSocket握手请求头有些服务端会根据WebSocket握手阶段的HTTP头如Sec-WebSocket-Protocol,Authorization等进行鉴权或路由。我们可以通过Playwright的route功能来修改这个Upgrade请求。// scenario_modify_handshake.js const { chromium } require(playwright); (async () { const browser await chromium.launch({ headless: false }); const context await browser.newContext(); const page await context.newPage(); // 拦截所有请求找到WebSocket的Upgrade请求并修改其请求头 await page.route(**/*, async route { const request route.request(); const headers request.headers(); // 判断是否为WebSocket Upgrade请求 if (headers[upgrade] headers[upgrade].toLowerCase() websocket) { console.log(拦截到WebSocket握手请求:, request.url()); // 添加或修改请求头 const newHeaders { ...headers, x-custom-token: my-secret-token-for-ws, // 添加自定义头 sec-websocket-protocol: chat, superchat // 修改子协议 }; // 使用修改后的头继续请求 await route.continue({ headers: newHeaders }); } else { // 非WebSocket请求直接继续 await route.continue(); } }); await page.goto(about:blank); // 创建一个会携带自定义头的WebSocket连接注意浏览器原生API不能直接设置这些头 // 通常这些头是由应用代码或框架如Socket.io在创建连接时设置的。 // 我们的route会确保这些头被正确添加。 await page.evaluate(() { // 假设你的应用这样创建连接 const ws new WebSocket(wss://your-ws-server.com); ws.onopen () console.log(Opened with custom headers (hopefully)); }); await page.waitForTimeout(3000); await browser.close(); })();实操心得修改握手请求头对于测试鉴权逻辑、兼容不同子协议版本等场景非常有用。但请注意浏览器原生的WebSocket构造函数不允许设置某些标准头如Authorization这些头通常由上层库或框架处理。route.continue({headers})会覆盖整个请求头所以要小心不要遗漏了必要的标准头如Host,Upgrade,Connection,Sec-WebSocket-Key等上面的示例使用扩展运算符...headers来保留原有头。5.3 场景三录制与回放WebSocket会话这对于调试和生成测试数据非常有帮助。思路是在录制阶段通过CDP监听并存储所有webSocketFrameSent和webSocketFrameReceived事件在回放阶段创建一个Mock WebSocket按照录制的顺序和时序发送消息。// scenario_record_and_playback.js const { chromium } require(playwright); const fs require(fs); (async () { const browser await chromium.launch({ headless: false }); const context await browser.newContext(); const page await context.newPage(); const cdpSession await context.newCDPSession(page); await cdpSession.send(Network.enable); const recordedFrames []; cdpSession.on(Network.webSocketFrameSent, ({ requestId, response }) { const payload Buffer.from(response.payloadData, base64).toString(utf-8); recordedFrames.push({ type: sent, timestamp: response.timestamp, payload, wsId: requestId }); console.log([Recorded SENT] ${payload}); }); cdpSession.on(Network.webSocketFrameReceived, ({ requestId, response }) { const payload Buffer.from(response.payloadData, base64).toString(utf-8); recordedFrames.push({ type: received, timestamp: response.timestamp, payload, wsId: requestId }); console.log([Recorded RECV] ${payload}); }); // 执行用户操作触发WebSocket通信 await page.goto(https://your-real-time-app.com); await page.waitForTimeout(10000); // 录制10秒 // 保存录制数据到文件 fs.writeFileSync(websocket_session.json, JSON.stringify(recordedFrames, null, 2)); console.log(录制完成共 ${recordedFrames.length} 条消息。); await browser.close(); // --- 回放阶段 (另一个脚本) --- console.log(\n--- 开始回放 ---); // 这里简化为直接打印实际回放需要创建一个Mock WebSocket并模拟发送接收 // 1. 读取录制文件 // 2. 在页面中注入一个Mock WebSocket类替换全局的WebSocket // 3. 根据recordedFrames的顺序用setTimeout模拟时间依次触发send和onmessage事件 // 4. 确保前端应用能像与真实服务器一样与Mock WebSocket交互 })();这是一个高级场景的骨架。完整实现需要考虑消息时序、连接生命周期open/close/error、以及如何让前端应用无感知地接入Mock WebSocket。社区有一些工具库如mock-socket可以辅助完成这部分工作你可以将其与Playwright的页面脚本注入结合使用。6. 常见问题与排查技巧实录在实际操作中你肯定会遇到各种问题。下面是我踩过的一些坑和对应的解决方案。6.1 问题监听不到任何WebSocket事件可能原因1监听器绑定太晚。排查WebSocket连接在page.on(‘websocket’)监听器绑定之前就已经建立了。解决确保在触发WebSocket连接的导航page.goto或JavaScript执行page.evaluate之前就绑定了监听器。最好在page对象创建后立即绑定。可能原因2CDP会话未启用Network域。排查使用了cdpSession监听但没收到事件。解决确认在监听事件前已经执行了await cdpSession.send(‘Network.enable’)。可能原因3目标页面使用了Service Worker或其他Worker内的WebSocket。排查page对象的主上下文监听不到Worker内的网络活动。解决这非常棘手。可以尝试通过cdpSession.send(‘Target.getTargets’)找到Worker的目标然后为其创建新的CDPSession进行监听但流程复杂。评估测试需求看是否必须覆盖此场景。6.2 问题注入的脚本修改不生效可能原因1脚本注入时机不对。排查页面在addInitScript执行前已经加载并执行了创建WebSocket的代码。解决addInitScript必须在page.goto()之前调用。它保证在页面框架初始化前执行。可能原因2WebSocket由iframe创建且跨域。排查控制台没有打印出注入脚本中的console.log。解决这是浏览器安全限制。对于跨域iframe你无法直接修改其内部的全局对象。解决方案包括在启动浏览器时使用--disable-web-security标志仅限测试环境极度危险。如果iframe的URL可控可以考虑使用page.route将其替换为一个同源的Mock HTML文件。重新思考测试策略是否可以不测试iframe内部逻辑。可能原因3页面使用了打包工具WebSocket实例被封装在闭包内。排查覆盖了window.WebSocket但应用代码可能从局部变量或模块内部引用了原生的构造函数。解决确保你的注入脚本在所有其他脚本之前执行。addInitScript通常可以做到。对于特别顽固的SPA可能需要结合page.evaluateOnNewDocument并在文档的head最早阶段执行覆盖。6.3 问题修改消息后连接异常断开可能原因1修改后的数据格式错误。排查服务器期望特定的数据格式如纯JSON字符串你修改后变成了非JSON字符串导致服务器端解析错误并主动断开连接。解决仔细分析服务器协议。修改数据时确保其编码和格式符合预期。对于二进制数据Blob,ArrayBuffer要特别小心处理。可能原因2时序问题导致状态不一致。排查在CDP事件回调中进行复杂的异步操作如网络请求来决定如何修改导致响应超时浏览器或服务器认为连接已失效。解决拦截修改逻辑应尽可能简单、同步。如果需要复杂判断考虑采用“监听-模拟”模式而不是直接拦截修改真实数据流。6.4 问题性能开销巨大可能原因监听了所有请求且处理逻辑复杂。排查页面WebSocket消息非常频繁如每秒上百条你的监听回调函数处理不过来。解决精准监听通过ws.url()或CDP事件中的requestId/url过滤只处理你关心的特定WebSocket连接。优化处理逻辑避免在回调中执行同步的繁重操作如复杂的字符串处理、频繁的console.log。采样对于纯粹的数据抓取场景如果不是每条消息都需要可以设计采样逻辑。6.5 通用调试技巧开启Playwright Debug Log在启动浏览器时设置环境变量DEBUGpw:api或DEBUGpw:protocol可以看到详细的协议通信有助于理解底层过程。结合浏览器开发者工具在无头模式下你可以通过headless: false打开浏览器手动打开DevTools的Network面板过滤WS类型直观地看到WebSocket连接和消息。这能帮你确认连接是否真的建立以及你的脚本是否在正确的时间点运行。分步验证不要一次性写完所有复杂逻辑。先写一个最简单的监听脚本确认能收到事件。然后逐步添加CDP会话、注入脚本等每步都验证效果。处理二进制消息WebSocket可以发送二进制数据。在CDP事件中payloadData是base64编码的。在注入脚本的send方法中data参数可能是ArrayBuffer或Blob。你需要使用instanceof进行检查并分别处理。// 在注入脚本的send劫持函数中 socket.send function(data) { let modifiedData data; if (typeof data string) { modifiedData modifyString(data); } else if (data instanceof ArrayBuffer) { // 处理ArrayBuffer const view new Uint8Array(data); // ... 修改view modifiedData view.buffer; } else if (data instanceof Blob) { // 处理Blob相对复杂可能需要用FileReader异步读取 console.warn(Blob类型直接修改较复杂可能需特殊处理); } return originalSend(modifiedData); };拦截和修改WebSocket通信确实比处理HTTP请求要复杂因为它涉及更持久的连接状态和底层协议。但一旦掌握了通过CDP和页面脚本注入这两种主要武器你就能应对绝大多数现代Web应用中的实时通信测试和数据抓取挑战。关键在于理解每种方法的适用场景和边界CDP用于深度监听和诊断页面脚本注入用于灵活的行为修改和模拟。在实际项目中将它们结合使用往往能发挥出最大的威力。