记一次redux-saga的项目实践总结

栏目: 服务器 · 发布时间: 6年前

内容简介:本文主要记录了在项目中使用redux-saga的一些总结,如有错误的地方欢迎指正互相学习。redux中的action仅支持原始对象(plain object),处理有副作用的action,需要使用中间件。中间件可以在发出action,到reducer函数接受action之间,执行具有副作用的操作。redux-thunk 和 redux-saga 是 redux 应用中最常用的两种异步流处理方式。

本文主要记录了在项目中使用redux-saga的一些总结,如有错误的地方欢迎指正互相学习。

redux中的action仅支持原始对象(plain object),处理有副作用的action,需要使用中间件。中间件可以在发出action,到reducer函数接受action之间,执行具有副作用的操作。

redux-thunk 和 redux-saga 是 redux 应用中最常用的两种异步流处理方式。

之前一直使用redux-thunk处理异步等副作用操作,在action中处理异步等副作用操作,此时的action是一个函数,以dispatch,getState作为形参,函数体内的部分可以执行异步。通过redux-thunk来处理异步,action可谓是多种多样,不利于维护。

记一次redux-saga的项目实践总结

redux-thunk

redux-thunk简单介绍

redux-thunk 的任务执行方式是从 UI 组件直接触发任务。

redux-thunk 中间件可以让action创建函数先不返回一个action对象,而是返回一个函数,函数传递两个参数(dispatch,getState),在函数体内进行业务逻辑的封装

redux-thunk 的主要思想是扩展 action,使得 action 从一个对象变成一个函数。

redux-thunk使用

比如下面是一个获取礼品列表的异步操作所对应的action

export default () => dispatch => {
  fetch('/api/goodList', {
    // fecth返回的是一个promise
    method: 'get', dataType: 'json' }).then(
    json => {
      var json = JSON.parse(json)
      if (json.code === 200) {
        dispatch({ type: 'init', data: json.data })
      }
    }, error => { console.log(error) }
  )
}

复制代码

从这个具有副作用的action中,我们可以看出,函数内部极为复杂。如果需要为每一个异步操作都如此定义一个action,显然action不易维护。

redux-thunk 缺点

总结一下 redux-thunk 缺点有如下几点:

  1. action 虽然扩展了,但因此变得复杂,后期可维护性降低;

  2. thunks 内部测试逻辑比较困难,需要mock所有的触发函数;

  3. 协调并发任务比较困难,当自己的 action 调用了别人的 action,别人的 action 发生改动,则需要自己主动修改;

  4. 业务逻辑会散布在不同的地方:启动的模块,组件以及thunks内部。

redux-saga

redux-saga简单介绍

redux-saga 文档中是这样介绍的:

redux-saga 是一个用于管理应用程序 Side Effect(副作用,例如异步获取数据,访问浏览器缓存等)的 library,它的目标是让副作用管理更容易,执行更高效,测试更简单,在处理故障时更容易。

刚开始了解Saga时,看官方解释,并不是很清楚到底是什么?Saga的副作用(side effects)到底是什么?

通读了官方文档后,大概了解到,副作用就是在action触发reduser之后执行的一些动作, 这些动作包括但不限于,连接网络,io读写,触发其他action。并且,因为Sage的副作用是通过redux的action触发的,每一个action,sage都会像reduser一样接收到。并且通过触发不同的action, 我们可以控制这些副作用的状态, 例如,启动,停止,取消。

所以,我们可以理解为Sage是一个可以用来处理复杂的异步逻辑的模块,并且由redux的action触发。

saga特点:

1.saga的应用场景是复杂异步,如长时事务LLT(long live.transcation)等业务场景。
2.方便测试,可以使用takeEvery打印logger。
3.提供takeLatest/takeEvery/throttle方法,可以便利的实现对事件的仅关注最近事件、关注每一次、事件限频
4.提供cancel/delay方法,可以便利的取消、延迟异步请求
5.提供race(effects),[…effects]方法来支持竞态和并行场景
6.提供channel机制支持外部事件
复制代码

Redux Saga适用于对事件操作有细粒度需求的场景,同时他们也提供了更好的可测试性。

redux-saga使用

注意::warning:redux-saga是通过ES6中的generator实现的(babel的基础版本不包含generator语法,因此需要在使用saga的地方import ‘babel-polyfill’)。

redux-saga本质是一个可以自执行的generator。

在 redux-saga 中,UI 组件自身从来不会触发任务,它们总是会 dispatch 一个 action 来通知在 UI 中哪些地方发生了改变,而不需要对 action 进行修改。redux-saga 将异步任务进行了集中处理,且方便测试。

所有的东西都必须被封装在 sagas 中。sagas 包含3个部分,用于联合执行任务:

worker saga

(1)做所有的工作,如调用 API,进行异步请求,并且获得返回结果

watcher saga

(2)监听被 dispatch 的 actions,当接收到 action 或者知道其被触发时,调用 worker saga 执行任务

(3)root saga

立即启动 sagas 的唯一入口

项目中我是这样用的,如果你有更好的实现方法请分享给我:

给redux添加中间件

在定义生成store的地方,引入并加入redux-sage中间件。

// store/index.js

import { createStore, applyMiddleware, compose } from 'redux'
import { routerMiddleware } from 'react-router-redux'
import createSagaMiddleware from 'redux-saga'
import createHistory from 'history/createHashHistory'
import { createLogger } from 'redux-logger'
import { rootSaga } from '../rootSaga'
import reducers from '../reducers/saga-reducer'

const history = createHistory()
const middlewareRouter = routerMiddleware(history)
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const loggerMiddleware = createLogger({ collapsed: true })
// 这是一个可以帮你运行saga的中间件
const sagaMiddleware = createSagaMiddleware()

const store = createStore(reducers,
  composeEnhancers(
  applyMiddleware(
  sagaMiddleware, middlewareRouter, loggerMiddleware
  )))

// 通过中间件执行或者说运行saga
sagaMiddleware.run(rootSaga, store)

window.store = store
export default store
复制代码

说明:程序启动时,run(rootSaga) 会开启 sagaMiddleware 对某些 action 进行监听,当后续程序中有触发 dispatch(action) (比如:用户点击)的时候,由于数据流会经过 sagaMiddleware,所以 sagaMiddleware 能够判断当前 action 是否有被监听?如果有,就会进行相应的操作(比如:发送一个异步请求);如果没有,则什么都不做。

// rootSaga.js

// 处理浏览器兼容问题
import 'babel-polyfill'
import { all,call } from 'redux-saga/effects'
import { lotterySagaRoot } from './components'
import { getchampionListFlow, getTabsListFlow } from './container'

export function* rootSaga () {
  yield all([call(getTabsListFlow),
    call(getchampionListFlow),
    call(lotterySagaRoot),
  ])
}

复制代码

rootSaga 是我们实际发送给Redux中间件的。

rootSaga 在应用程序启动时被触发一次,可以被认为是在后台运行的进程,监视着所有的动作派发到仓库(store)。

我们单拿出一个 getTabsListFlow 这个saga来进行讲解究竟发生了什么?

写到这里有必要说一下业务逻辑了,getTabsListFlow这个函数是一个 watcher saga ,它 watch 的谁呢? getTabsList 这个 worker saga 函数,废话不多说看代码:

// 处理浏览器兼容问题
import 'babel-polyfill'
import { call, put, take, fork } from 'redux-saga/effects'
import * as types from '../../action_type'
import { lists } from '../../actions/server'

const { GETLIST, TABS_UPDATE, START_FETCH, FETCH_ERROR, FETCH_END } = types

//----worker saga

function* getTabsList (tabs, rule, env) {
  yield put({ type: START_FETCH })
  try {
    return yield call(lists, tabs, rule, env)
  } catch (err) {
    yield put({ type: FETCH_ERROR,err})
  } finally {
    yield put({ type: FETCH_END })
  }
}

//-----watcher saga

export default function* getTabsListFlow() {
  while (true) {
    const { tabs, rule, env } = yield take(GETLIST)
    const { code, data } = yield call(getTabsList, tabs, rule, env)
    yield put({ type: TABS_UPDATE, data, code })
  }
}

复制代码

上面的代码可以看到,getTabsListFlow这个函数响应一个action,“GETLIST”,获取tabs, rule, env这三个参数传给,getTabsList,这个函数,然后把获取到的结果通过响应一个TABS_UPDATE这个action.type给reducer去出更新数据到页面。

那么这些call, put, take, fork这些API后面会讲,总之就是让函数执行获取数据嘛。我们现在需要知道数据流是怎样实现的?

问题1:

“GETLIST”这个action.type代表的是哪个函数,这个函数怎么获取到tabs, rule, env这三个参数的?看代码,其实真的很简单。。。

// actions/index.js
export function getList(tabs, rule, env) {
  return {
    type: GETLIST,
    tabs,
    rule,
    env,
  }
}

复制代码

看到没有我导出了这样一个函数,给了它一个action.type就是叫GETLIST, yield take(GETLIST)就是让这个函数执行了,这三个参数也是这样传递进来的,我只需要在页面上引入这个函数去让个函数执行并传递参数就行了。

import React, { Component } from 'react'
import { bindActionCreators } from 'redux'
import { Link } from 'react-router-dom'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { getList } from '../../actions/index'

class List extends Component {
  state = {
    tabs: 'anchor',
    rule: 'hour',
    active: 'anchor',
    hover: 'allanchor',
    visible: false,
  }

  componentDidMount() {
    const { tabs, rule } = this.state
    this.props.getList(tabs, rule, env)
  }
  
  
  
  ....省略一些代码
  
  
  
  List.propTypes = {
  getList: PropTypes.func
}
function mapStateToProps(state) {
  return {
    ...state,
  }
}

function mapDispatchToProps(dispatch) {
  return {
    getList: bindActionCreators(getList, dispatch),
  }
}
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(List)
复制代码

这样的话" const { tabs, rule, env } = yield take(GETLIST) "这一段代码就获取到我传递的参数了。

这里设计到了redux的知识,参考:阮一峰Redux 入门教程。

问题2:

接下来 yield call(getTabsList, tabs, rule, env) ,让getTabsList执行,里面发了一个请求lists执行并传递参数。

lists是什么?其实它就是一个异步请求。

/**
 * 排行榜
 *
 * @param {String} type
 * @param {String} rule
 * @return {Promise}
 */
export const lists = (type, rule) => req({
  endpoint: `${APP_NAME}/data/${type}/${rule}/${env}`,
  method: GET,
})
复制代码

这个是一个被封装好的fectch请求。类似于这样

// 通过fetch获取百度的错误提示页面
fetch('https://www.baidu.com/search/error.html?a=1&b=2', { 

// 在URL中写上传递的参数
    method: 'GET'
  })
  .then((res)=>{
    return res.text()
  })
  .then((res)=>{
    console.log(res)
  })
复制代码

接下来执行到这里 const { code, data } = yield call(getTabsList, tabs, rule, env)

yield put({ type: TABS_UPDATE, data, code }) ,到这里我们已经通过请求获取到我们想要的数据了,下一步就是去reducer里生成新的state了。

const userReducer = (state = defaultState, action = {}) => {
  const { type} = action;
  switch (type) {
   case TABS_UPDATE:
    return Object.assign({}, state, { list: action.data, loading: false })
    default: return state;
  }
};
复制代码

总结一下:

(1)引入的 redux-saga/effects 都是纯函数,每个函数构造一个特殊的对象,其中包含着中间件需要执行的指令,如:call(lists, tabs, rule, env) 返回一个类似于 {type: CALL, function: lists, args: [tabs, rule, env]} 的对象。

(2)在 watcher saga getTabsListFlow 中:

首先 yield take(GETLIST) 来告诉中间件我们正在等待一个类型为 GETLIST 的 action,然后中间件会暂停执行 getTabsListFlow generator 函数,直到 GETLIST action(getList) 被 dispatch。一旦我们获得了匹配的 action,中间件就会恢复执行 generator 函数。

下一条指令 const { code, data } = yield call(getTabsList, tabs, rule, env) 告诉中间件去执行getTabsList,并把{tabs, rule, env} 作为 getTabsList 函数的参数传递。中间件会触发 getTabsList generator。

(3)在 worker saga getTabsList 中, yield call(lists, tabs, rule, env) 指示中间件去调用 fetch 函数,同时,会阻塞getTabsList 的执行,中间件会停止 generator 函数,直到 fetch 返回的 Promiseresolved (或 rejected ),然后才恢复执行 generator 函数。

借一张基于 redux-saga 的一次 完整单向数据流单项数据流的图:

记一次redux-saga的项目实践总结

到此为止就是我在项目中使用redux-saga针对于其中一个请求来实现的数据处理。

下面开始介绍一些API的使用了:

redux-saga API

安装啥的步骤直接略过....

Effects

前面说到,saga 是一个 generator function,这就意味着它的执行原理必然是下面这样:

function isPromise(value) {
    return value && typeof value.then === 'function';
}

const iterator = saga(/* ...args */);

// 方法一:
// 一步一步,手动执行
let result;

result = iterator.next();
result = iterator.next(result.value);
result = iterator.next(result.value);
// ...
// done!!



// 方法二:
// 函数封装,自主执行
function next(args) {
  const result = iterator.next(args);
  if (result.done) {
    // 执行结束
    console.log(result.value);
  } else {
    // 根据 yielded 的值,决定什么时候继续执行(resume) 
    if (isPromise(result.value)) {
      result.value.then(next);
    } else {
      next(result.value)
    }
  }
}

next();

复制代码

也就是说, generator function 在未执行完前(即: result.done === false ),它的控制权始终掌握在 执行者(caller)手中,即:

  • caller 决定什么时候 恢复(resume)执行。

  • caller 决定每次 yield expression 的返回值。

而 caller 本身要实现上面上述功能需要依赖原生 API :iterator.next(value) ,value 就是 yield expression 的返回值。

举个例子:

function* hello() {
  const value = yield Promise.reslove('hello saga');
  console.log('value: ', value); // value??
}
复制代码

单纯的看 hello 函数,没人知道 value 的值会是多少?

这完全取决于 gen 的执行者(caller),如果使用上面的 next 方法来执行它,value 的值就是 'hello saga',因为 next 方法对 expression 为 promise 时,做了特殊处理(这不就是缩小版的 co 么~ wow~⊙o⊙)。

换句话说, expression 可以是任何值,关键是 caller 如何来解释 expression,并返回合理的值

以此结论,推理来看:

大家熟知的 co 可以认为是一个 caller,它解释的 expression 是:promise/thunk/generator function/iterator 等。

这里的 sagaMiddleware 也算是一个 caller,它主要解释的 expression 就是 effect(当然还可以是 promise/iterator) 。

讲了这么多,那么 effect 到底是什么呢?先来看看官方解释:

An effect is a plain JavaScript Object containing some instructions to be executed by the saga middleware.

意思是说:effect 本质上是一个普通对象,包含着一些指令信息,这些指令最终会被 saga middleware 解释并执行。

用一段代码​来解释上述这句话:

function* fetchData() {
  // 1. 创建 effect
  const effect = call(ajax.get, '/userLogin');
  console.log('effect: ', effect);
  // effect:
  // {
  //   CALL: {
  //     context: null,
  //     args: ['/userLogin'],
  //     fn: ajax.get,
  //   }
  // }


  // 2. 执行 effect,即:调用 ajax.get('/userLogin')
  const value = yield effect;
  console.log('value: ', value);
}
复制代码

可以明显的看出:

call方法用来创建 effect 对象,被称作是 effect factory。

yield语法将 effect 对象 传给 sagaMiddleware,被解释执行,并返回值。

这里的 call effect 表示执行 ajax.get('user/Login') ,又因为它的返回值是 promise , 为了等待异步结果返回, fetchData 函数会暂时处于 阻塞 状态。

除了上述所说的 call effect 之外,redux-saga 还提供了很多其他 effect 类型,它们都是由对应的 effect factory 生成,在 saga 中应用于不同的场景,比较常用的是:

takeEvery

允许 多个请求同时执行 ,不管之前是否还有一个或多个请求尚未结束。

// 首先我们创建一个将执行异步 action 的任务:
import { call, put,takeEvery } from 'redux-saga/effects'

export function* fetchData(action) {
   try {
      const data = yield call(Api.fetchUser, action.payload.url);
      yield put({type: "FETCH_SUCCEEDED", data});
   } catch (error) {
      yield put({type: "FETCH_FAILED", error});
   }
}

//然后在每次 FETCH_REQUESTED action 被发起时启动上面的任务。
function* watchFetchData() {
  yield* takeEvery('FETCH_REQUESTED', fetchData)
}
复制代码

在上面的例子中,takeEvery 允许多个 fetchData 实例同时启动。在某个特定时刻,尽管之前还有一个或多个 fetchData 尚未结束,我们还是可以启动一个新的 fetchData 任务,

如果我们只想得到最新那个请求的响应(例如,始终显示最新版本的数据)。我们可以使用 takeLatest 辅助函数。

takeLatest

作用同takeEvery一样,唯一的区别是它只关注最后,也就是最近一次发起的异步请求,如果上次请求还未返回,则会被取消。

function* watchFetchData() {
  yield takeLatest('FETCH_REQUESTED', fetchData)
}

复制代码

call

all用来调用异步函数,将异步函数和函数参数作为call函数的参数传入,返回一个js对象。saga引入他的主要作用是方便测试,同时也能让我们的代码更加规范化。

同js原生的call一样,call函数也可以指定this对象,只要把this对象当第一个参数传入call方法就好了

saga同样提供apply函数,作用同call一样,参数形式同js原生apply方法。

// 模拟数据异步获取
function fn() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('hello saga');
    }, 2000);
  });
}

function* fetchData() {
  // 等待 2 秒后,打印欢迎语(阻塞)
  const greeting = yield call(fn);
  console.log('greeting: ', greeting);
    
}
复制代码

fork

非阻塞任务调用机制:上面我们介绍过 call 可以用来发起异步操作,但是相对于 generator 函数来说, call 操作是阻塞的,只有等 promise 回来后才能继续执行,而fork是非阻塞的 ,当调用 fork 启动一个任务时,该任务在后台继续执行,从而使得我们的执行流能继续往下执行而不必一定要等待返回。

还是上面的栗子:

// 模拟数据异步获取
function fn() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('hello saga');
    }, 2000);
  });
}

function* fetchData() {

  // 立即打印 task 对象(非阻塞)
  const task = yield fork(fn);
  console.log('task: ', task);
}
复制代码

显然, fork 的异步非阻塞特性更适合于在后台运行一些不影响主流程的代码(比如:后台打点/开启监听),这往往是加快页面渲染的一种方式。

put

作用和 redux 中的 dispatch 相同。

yield put({ type: 'CLICK_BTN' });

复制代码

select

作用和 redux thunk 中的 getState 相同。

const id = yield select(state => state.id);

复制代码

take

take(pattern) 用以下规则来解释 pattern:

1.如果调用 take 时参数为空,或者传入 '*',那将会匹配所有发起的 action(例如,take() 会匹配所有的 action)。

2.如果是一个函数,action 会在 pattern(action) 返回为 true 时被匹配(例如,take(action => action.entities) 会匹配那些 entities 字段为真的 action)。

3.如果是一个字符串,action 会在 action.type === pattern 时被匹配(例如,take(INCREMENT_ASYNC))。

4.如果参数是一个数组,会针对数组所有项,匹配与 action.type 相等的 action(例如,take([INCREMENT, DECREMENT]) 会匹配 INCREMENT 或 DECREMENT 类型的 action)。
复制代码

当在 generator 中使用 take 语句等待 action 时, generator 被阻塞,等待 action 被分发,然后继续往下执行,有种 Event.once() 事件监听的感觉。

export function* getAdDataFlow() {
    while (true){
        let request = yield take(homeActionTypes.GET_AD);
        let response = yield call(getAdData,request.url);
        yield put({type:homeActionTypes.GET_AD_RESULT_DATA,data:response.data})
    }
}
复制代码

take VS tackEvery

takeEvery 只是监听每个 action ,然后执行处理函数。对于合适响应 action 和如何响应 action , tackEvery 没有权限。

最大的区别:

take 只有在执行流达到时才回响应 action ,而 takeEvery 则一经注册,都会响应 action

all

all 提供了一种并行执行异步请求的方式。之前介绍过执行异步请求的api中,大都是阻塞执行,只有当一个call操作放回后,才能执行下一个 call 操作, call 提供了一种类似 Promise 中的 all 操作,可以将多个异步操作作为参数参入 all 函数中, 如果有一个 call 操作失败或者所有 call 操作都成功返回,则本次all操作执行完毕。

import { all, call } from 'redux-saga/effects'
 
// correct, effects will get executed in parallel
const [users, repos]  = yield all([
  call(fetch, '/users'),
  call(fetch, '/repos')
])

复制代码

race

有时候当我们并行的发起多个异步操作时,我们并不一定需要等待所有操作完成,而只需要有一个操作完成就可以继续执行流。这就是 race 的用处。

他可以并行的启动多个异步请求,只要有一个 请求返回(resolved或者reject), race 操作接受正常返回的请求,并且将剩余的请求取消。

import { race, take, put } from 'redux-saga/effects'
 
function* backgroundTask() {
  while (true) { ... }
}
 
function* watchStartBackgroundTask() {
  while (true) {
    yield take('START_BACKGROUND_TASK')
    yield race({
      task: call(backgroundTask),
      cancel: take('CANCEL_TASK')
    })
  }
}
复制代码

actionChannel

在之前的操作中,所有的 action 分发是顺序的,但是对 action 的响应是由异步任务来完成,也即是说对action的处理是无序的。

如果需要对 action 的有序处理的话,可以使用 actionChannel 函数来创建一个 action 的缓存队列,但一个 action 的任务流程处理完成后,才可是执行下一个任务流。

import { take, actionChannel, call, ... } from 'redux-saga/effects'
 
function* watchRequests() {
  // 1- Create a channel for request actions
  const requestChan = yield actionChannel('REQUEST')
  while (true) {
    // 2- take from the channel
    const {payload} = yield take(requestChan)
    // 3- Note that we're using a blocking call
    yield call(handleRequest, payload)
  }
}
 
function* handleRequest(payload) { ... }
复制代码

Error Handling

在 saga 中,无论是请求失败,还是代码异常,均可以通过 try catch 来捕获。

倘若访问一个接口出现代码异常,可能是网络请求问题,也可能是后端数据格式问题,但不管怎样,给予日志上报或友好的错误提示是不可缺少的,这也往往体现了代码的健壮性,一般会这么做:

function* saga() {
 try {
   const data = yield call(fetch, '/someEndpoint');
   return data;
 }  catch (error) {
    yield put(onError(error));
  }
}
复制代码

Watcher/Worker

指的是一种使用两个单独的 Saga 来组织控制流的方式。

Watcher: 监听发起的 action 并在每次接收到 actionfork 一个 worker

Worker: 处理 action 并结束它。

function* watcher() {
  while(true) {
    const action = yield take(ACTION)
    yield fork(worker, action.payload)
  }
}

function* worker(payload) {
  // ... do some stuff
}

复制代码

事实上因为项目的局限性很多API并没有用上,可以根据项目的实际需求使用这些API,因为它们真的很有意思!!

以上~


以上所述就是小编给大家介绍的《记一次redux-saga的项目实践总结》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

白话大数据与机器学习

白话大数据与机器学习

高扬、卫峥、尹会生 / 机械工业出版社 / 2016-6 / 69

本书通俗易懂,有高中数学基础即可看懂,同时结合大量案例与漫画,将高度抽象的数学、算法与应用,与现实生活中的案例和事件一一做了关联,将源自生活的抽象还原出来,帮助读者理解后,又带领大家将这些抽象的规律与算法应用于实践,贴合读者需求。同时,本书不是割裂讲解大数据与机器学习的算法和应用,还讲解了其生态环境与关联内容,让读者更全面地知晓渊源与未来,是系统学习大数据与机器学习的不二之选: ·大数据产业......一起来看看 《白话大数据与机器学习》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具