上一篇文章,我介绍了 Redux 的基本做法:用户发出 Action,Reducer 函数算出新的 State,View 重新渲染。
但是,一个关键问题没有解决:异步操作怎么办?Action 发出以后,Reducer 立即算出 State,这叫做同步;Action 发出以后,过一段时间再执行 Reducer,这就是异步。
怎么才能 Reducer 在异步操作结束后自动执行呢?这就要用到新的工具:中间件(middleware)。
一、中间件的概念
为了理解中间件,让我们站在框架作者的角度思考问题:如果要添加功能,你会在哪个环节添加?
(1)Reducer:纯函数,只承担计算 State 的功能,不合适承担其他功能,也承担不了,因为理论上,纯函数不能进行读写操作。
(2)View:与 State 一一对应,可以看作 State 的视觉层,也不合适承担其他功能。
(3)Action:存放数据的对象,即消息的载体,只能被别人操作,自己不能进行任何操作。
想来想去,只有发送 Action 的这个步骤,即store.dispatch()
方法,可以添加功能。举例来说,要添加日志功能,把 Action 和 State 打印出来,可以对store.dispatch
进行如下改造。
let next = store.dispatch; store.dispatch = function dispatchAndLog(action) { console.log('dispatching', action); next(action); console.log('next state', store.getState()); }
上面代码中,对store.dispatch
进行了重定义,在发送 Action 前后添加了打印功能。这就是中间件的雏形。
中间件就是一个函数,对store.dispatch
方法进行了改造,在发出 Action 和执行 Reducer 这两步之间,添加了其他功能。
二、中间件的用法
本教程不涉及如何编写中间件,因为常用的中间件都有现成的,只要引用别人写好的模块即可。比如,上一节的日志中间件,就有现成的redux-logger模块。这里只介绍怎么使用中间件。
import { applyMiddleware, createStore } from 'redux'; import createLogger from 'redux-logger'; const logger = createLogger(); const store = createStore( reducer, applyMiddleware(logger) );
上面代码中,redux-logger
提供一个生成器createLogger
,可以生成日志中间件logger
。然后,将它放在applyMiddleware
方法之中,传入createStore
方法,就完成了store.dispatch()
的功能增强。
这里有两点需要注意:
(1)createStore
方法可以接受整个应用的初始状态作为参数,那样的话,applyMiddleware
就是第三个参数了。
const store = createStore( reducer, initial_state, applyMiddleware(logger) );
(2)中间件的次序有讲究。
const store = createStore( reducer, applyMiddleware(thunk, promise, logger) );
上面代码中,applyMiddleware
方法的三个参数,就是三个中间件。有的中间件有次序要求,使用前要查一下文档。比如,logger
就一定要放在最后,否则输出结果会不正确。
三、applyMiddlewares()
看到这里,你可能会问,applyMiddlewares
这个方法到底是干什么的?
它是 Redux 的原生方法,作用是将所有中间件组成一个数组,依次执行。下面是它的源码。
export default function applyMiddleware(...middlewares) { return (createStore) => (reducer, preloadedState, enhancer) => { var store = createStore(reducer, preloadedState, enhancer); var dispatch = store.dispatch; var chain = []; var middlewareAPI = { getState: store.getState, dispatch: (action) => dispatch(action) }; chain = middlewares.map(middleware => middleware(middlewareAPI)); dispatch = compose(...chain)(store.dispatch); return {...store, dispatch} } }
上面代码中,所有中间件被放进了一个数组chain
,然后嵌套执行,最后执行store.dispatch
。可以看到,中间件内部(middlewareAPI
)可以拿到getState
和dispatch
这两个方法。
四、异步操作的基本思路
理解了中间件以后,就可以处理异步操作了。
同步操作只要发出一种 Action 即可,异步操作的差别是它要发出三种 Action。
- 操作发起时的 Action
- 操作成功时的 Action
- 操作失败时的 Action
以向服务器取出数据为例,三种 Action 可以有两种不同的写法。
// 写法一:名称相同,参数不同 { type: 'FETCH_POSTS' } { type: 'FETCH_POSTS', status: 'error', error: 'Oops' } { type: 'FETCH_POSTS', status: 'success', response: { ... } } // 写法二:名称不同 { type: 'FETCH_POSTS_REQUEST' } { type: 'FETCH_POSTS_FAILURE', error: 'Oops' } { type: 'FETCH_POSTS_SUCCESS', response: { ... } }
除了 Action 种类不同,异步操作的 State 也要进行改造,反映不同的操作状态。下面是 State 的一个例子。
let state = { // ... isFetching: true, didInvalidate: true, lastUpdated: 'xxxxxxx' };
上面代码中,State 的属性isFetching
表示是否在抓取数据。didInvalidate
表示数据是否过时,lastUpdated
表示上一次更新时间。
现在,整个异步操作的思路就很清楚了。
- 操作开始时,送出一个 Action,触发 State 更新为"正在操作"状态,View 重新渲染
- 操作结束后,再送出一个 Action,触发 State 更新为"操作结束"状态,View 再一次重新渲染
五、redux-thunk 中间件
异步操作至少要送出两个 Action:用户触发第一个 Action,这个跟同步操作一样,没有问题;如何才能在操作结束时,系统自动送出第二个 Action 呢?
奥妙就在 Action Creator 之中。
class AsyncApp extends Component { componentDidMount() { const { dispatch, selectedPost } = this.props dispatch(fetchPosts(selectedPost)) } // ...
上面代码是一个异步组件的例子。加载成功后(componentDidMount
方法),它送出了(dispatch
方法)一个 Action,向服务器要求数据 fetchPosts(selectedSubreddit)
。这里的fetchPosts
就是 Action Creator。
下面就是fetchPosts
的代码,关键之处就在里面。
const fetchPosts = postTitle => (dispatch, getState) => { dispatch(requestPosts(postTitle)); return fetch(`/some/API/${postTitle}.json`) .then(response => response.json()) .then(json => dispatch(receivePosts(postTitle, json))); }; }; // 使用方法一 store.dispatch(fetchPosts('reactjs')); // 使用方法二 store.dispatch(fetchPosts('reactjs')).then(() => console.log(store.getState()) );
上面代码中,fetchPosts
是一个Action Creator(动作生成器),返回一个函数。这个函数执行后,先发出一个Action(requestPosts(postTitle)
),然后进行异步操作。拿到结果后,先将结果转成 JSON 格式,然后再发出一个 Action( receivePosts(postTitle, json)
)。
上面代码中,有几个地方需要注意。
(1)
fetchPosts
返回了一个函数,而普通的 Action Creator 默认返回一个对象。(2)返回的函数的参数是
dispatch
和getState
这两个 Redux 方法,普通的 Action Creator 的参数是 Action 的内容。(3)在返回的函数之中,先发出一个 Action(
requestPosts(postTitle)
),表示操作开始。(4)异步操作结束之后,再发出一个 Action(
receivePosts(postTitle, json)
),表示操作结束。
这样的处理,就解决了自动发送第二个 Action 的问题。但是,又带来了一个新的问题,Action 是由store.dispatch
方法发送的。而store.dispatch
方法正常情况下,参数只能是对象,不能是函数。
这时,就要使用中间件redux-thunk
。
import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; import reducer from './reducers'; // Note: this API requires redux@>=3.1.0 const store = createStore( reducer, applyMiddleware(thunk) );
上面代码使用redux-thunk
中间件,改造store.dispatch
,使得后者可以接受函数作为参数。
因此,异步操作的第一种解决方案就是,写出一个返回函数的 Action Creator,然后使用redux-thunk
中间件改造store.dispatch
。
六、redux-promise 中间件
既然 Action Creator 可以返回函数,当然也可以返回其他值。另一种异步操作的解决方案,就是让 Action Creator 返回一个 Promise 对象。
这就需要使用redux-promise
中间件。
import { createStore, applyMiddleware } from 'redux'; import promiseMiddleware from 'redux-promise'; import reducer from './reducers'; const store = createStore( reducer, applyMiddleware(promiseMiddleware) );
这个中间件使得store.dispatch
方法可以接受 Promise 对象作为参数。这时,Action Creator 有两种写法。写法一,返回值是一个 Promise 对象。
const fetchPosts = (dispatch, postTitle) => new Promise(function (resolve, reject) { dispatch(requestPosts(postTitle)); return fetch(`/some/API/${postTitle}.json`) .then(response => { type: 'FETCH_POSTS', payload: response.json() }); });
写法二,Action 对象的payload
属性是一个 Promise 对象。这需要从redux-actions
模块引入createAction
方法,并且写法也要变成下面这样。
import { createAction } from 'redux-actions'; class AsyncApp extends Component { componentDidMount() { const { dispatch, selectedPost } = this.props // 发出同步 Action dispatch(requestPosts(selectedPost)); // 发出异步 Action dispatch(createAction( 'FETCH_POSTS', fetch(`/some/API/${postTitle}.json`) .then(response => response.json()) )); }
上面代码中,第二个dispatch
方法发出的是异步 Action,只有等到操作结束,这个 Action 才会实际发出。注意,createAction
的第二个参数必须是一个 Promise 对象。
看一下redux-promise
的源码,就会明白它内部是怎么操作的。
export default function promiseMiddleware({ dispatch }) { return next => action => { if (!isFSA(action)) { return isPromise(action) ? action.then(dispatch) : next(action); } return isPromise(action.payload) ? action.payload.then( result => dispatch({ ...action, payload: result }), error => { dispatch({ ...action, payload: error, error: true }); return Promise.reject(error); } ) : next(action); }; }
从上面代码可以看出,如果 Action 本身是一个 Promise,它 resolve 以后的值应该是一个 Action 对象,会被dispatch
方法送出(action.then(dispatch)
),但 reject 以后不会有任何动作;如果 Action 对象的payload
属性是一个 Promise 对象,那么无论 resolve 和 reject,dispatch
方法都会发出 Action。
中间件和异步操作,就介绍到这里。下一篇文章将是最后一部分,介绍如何使用react-redux
这个库。
(完)
surersen 说:
刚学习完阮老师的Redux 入门教程(一),写的非常通俗易懂,学习了,谢谢。
2016年9月20日 10:47 | # | 引用
Micooz 说:
Redux在处理异步轮询的业务场景时十分尴尬:
1. 应用响应用户操作,开始轮询,每隔5秒dispatch一个异步action。
2. 应用根据上一次action的reducer结果,自动改变两次状态(doing, done)。
3. 轮询期间,应用需要根据返回结果确定是否结束轮询。
麻烦就出在步骤3中,判断逻辑应该写在component中呢还是reducer中呢?
1. 写在component的render方法中显然不合适,componentWillUpdate或者componentDidUpdate也不太好,因为组件很可能受其他action的影响而改变状态,这个时候组件的判断逻辑就会变得繁琐。
2. 写在reducer中也很尴尬,这个时候reducer要控制业务逻辑,结束轮询需要调用clearInterval,这就意味着每次发action时要带上setInterval的返回值。
Redux处理一次性异步action很方便,但对于多异步action或者链式异步action,没有合适的办法进行过程控制。对于这种情形,抛开Redux使用常规方法也许是更好的选择。
不知阮老师有何建议?
2016年9月20日 10:59 | # | 引用
seven 说:
不错不错,下面该实践了
2016年9月20日 12:15 | # | 引用
York 说:
“奥秒就在 Action Creator 之中”,老师字打错了,应该是“奥妙”吧!
2016年9月20日 13:55 | # | 引用
侯侯 说:
上周直接看的官方教程没太看懂,老师写的真是简明易懂,期待下一篇
2016年9月20日 15:14 | # | 引用
阮一峰 说:
@York: 谢谢指出,已经改正。
@Micooz:轮询的开始和结束判断,都写在 Action Creator 里面。
2016年9月21日 13:55 | # | 引用
孙梦华 说:
@Micooz:
你可以在dispatch之后再返回一个promise结果,例如:
2016年9月21日 17:20 | # | 引用
dp0qb 说:
谢谢老师!!!
2016年9月21日 20:25 | # | 引用
赵越 说:
很认真的拜读了您的文章,写的很赞。
对redux-promise中间件中的一段话不太理解。
如果 Action 对象的payload属性是一个 Promise 对象,那么无论 resolve 和 reject,dispatch方法都会发出 Action。
我个人初步觉得 写法一,返回值是一个 Promise 对象,也可以在reject时发出action吧?
敬请赐教哈
2016年10月 4日 21:42 | # | 引用
cisen 说:
compose真的是非常巧妙,最后生成一个洋葱剖面图的函数,dispatch(action)在最中心,中间件在两边。两句研究了半天惨研究出来,真实够厉害的。自己也真是渣T_T
2016年10月22日 22:13 | # | 引用
lazyou 说:
到了第二章就看不懂了, 我到底缺少了什么技能。。。。。
2016年10月27日 10:23 | # | 引用
好炫 说:
老师没看懂
异步的话直接settimeout 然后执行dispathc不就可以了吗?
为什么需要绕一个弯,先dispatch(fn) 传入一个函数然后再在该函数内settimeout 然后再dispatch? 这里实在是想不明白,而且我写了一下确实好像没什么不一样 希望老师指点下
2016年10月29日 10:52 | # | 引用
coolpot 说:
export default function applyMiddleware(...middlewares)
问一个比较入门的问题。
请问这里的三个点是什么意思啊
2016年11月 4日 11:16 | # | 引用
超越深蓝 说:
任意长参数
2016年11月 6日 14:13 | # | 引用
onein 说:
表示没有明白要用到promise这个中间件来干什么。 获取到promise对象后干嘛用。。。
2016年11月 8日 00:26 | # | 引用
cisen 说:
@Micooz:
你需要sagas
2016年11月25日 15:29 | # | 引用
laden666666 说:
如果要将异步的状态信息也写入redux里面,最少需要发两个action:第一个是同步的action,是将异步开始这个状态写入了redux;第二个action是异步方法的结果,可能是请求成功的action,也可能是失败的action,因为不可能同时成功和失败,所以阮老师说异步方法有3种action,但一次异步方法发出两种就够。
接下来阮老师说了两种方法做这件事,第一种是createAction配合redux-thunk中间件,另一种是createAction配合redux-promise中间件。这些方法好处就是你只需要调用一次就可以自动地发出这两个action,用起来比较方便。
两个方法中都没有用到settimeout,都是用promise做的,这和settimeout完全不是一回事
2016年11月26日 10:59 | # | 引用
咸鱼 说:
阮老师,redux-thunk、redux-promise、redux-saga这三个异步方案孰优孰劣您有什么看法不?
2016年12月14日 20:36 | # | 引用
lihuihero 说:
写异步的先把自己整晕了。
2016年12月15日 19:15 | # | 引用
MLGB 说:
redux早晚得死,MLGB,搞的这么麻烦。自古以来,能在历史中一直被延续的框架,一定是操作简易的。
2016年12月28日 15:40 | # | 引用
曹东旭 说:
createAction(
'FETCH_POSTS',
fetch(`/some/API/${postTitle}.json`)
.then(response => response.json())
)
阮老师,您这段代码create Action的调用方式是不是有问题,官网上貌似应该
createAction(
'FETCH_POSTS')(
fetch(`/some/API/${postTitle}.json`)
.then(response => response.json())
)
2017年1月 6日 11:16 | # | 引用
高峰 说:
感谢老师,说的很好,可是我的理解能力有点差,慢慢体会
2017年1月16日 09:20 | # | 引用
毛庭峰 说:
这部分真的好难理解啊。表示看不懂
2017年1月20日 10:16 | # | 引用
Nesus 说:
redux中的compose更新了,执行顺序从右向左。
2017年2月17日 10:24 | # | 引用
黄鑫慧 说:
感谢,用这样写的方式,终于成功了
2017年3月 2日 20:04 | # | 引用
wulusai 说:
大神 您学习redux参考了哪些资料 ,网址可以发出来么
2017年3月 5日 22:10 | # | 引用
三只要有你 说:
阮老师,您的这块代码少加了一个括号吧,
.then(response => {
type: 'FETCH_POSTS',
payload: response.json()
});
.then(response => ({
type: 'FETCH_POSTS',
payload: response.json()
}));
2017年3月10日 18:05 | # | 引用
余皖林 说:
这里应该有误:下面是官方写法
第一处、
import ReduxThunk from 'redux-thunk' // no changes here
2017年3月31日 16:42 | # | 引用
涛涛 说:
正在跟着老师学
2017年4月17日 16:41 | # | 引用
wujohns 说:
这个做法好丑,还不如把异步放在外层,里层包装dispatch
2017年4月22日 14:44 | # | 引用
王一之 说:
自从接触了redux,不知道代码写在哪……
2017年6月11日 21:18 | # | 引用
您的大名 说:
感觉现在react这一套有点故弄玄虚,为了秀某些编程思想,想的太远了,《易》曰:“易则易知,简则易从”
2017年6月13日 17:56 | # | 引用
coderzzp 说:
在中间件redux-logger这一块有一个小更新:
import createLogger from 'redux-logger';
const logger = createLogger();
现在直接引入就是一个logger,不需要再调用函数,变成如下:
import logger from 'redux-logger';
2017年8月 4日 09:37 | # | 引用
大明 说:
这叫责任链模式,compose是它的一种应用,完整的责任链的定义所有的含义更丰富。
2017年8月 4日 10:45 | # | 引用
小明 说:
感觉前面写得很好,文章第六部分“redux-promise 中间件”说的好晦涩难懂,自己捋了半天都没有很明白。贴的源码也不容易看懂。
其实我觉得您可以改成,把中间件dedux-promise和promise类型的action合成一个顺序流程来讲,他们是完整的调用流程是如何的。
2017年8月 4日 12:48 | # | 引用
J 说:
有人发现官网的例子,每次启动服务时,reducer都会触发三次default吗?。。
2017年8月10日 13:49 | # | 引用
Even 说:
您的教程对我很有启发,看来以后的路还长。不过相比于vuex,redux的确太麻烦了。
2017年8月11日 00:36 | # | 引用
张三 说:
要不要这么复杂,这么多专业术语,表示一脸懵逼,哎...真心看不懂
2017年9月13日 17:15 | # | 引用
一网友 说:
我也没看懂,不知道我缺少什么技能!函数感觉太多了,根本分不清哪些是自定义函数,哪些是库函数!仍然要感谢峰哥!
2017年9月18日 19:39 | # | 引用
ngaiwe 说:
阮老师 请教问题这个Action Creator写在什么地方?是新建一个文件夹根据项目创建多个?然后分别import 。还是写在组件中?
2017年10月28日 09:07 | # | 引用
wongjorie 说:
勘误 => applyMiddleware()
2017年11月 7日 14:22 | # | 引用
water 说:
作者在介绍redux-thunk中间件的用法的时,介绍store.dispatch()的参数是一个函数,结合作者的代码:return fetch(`/some/API/${postTitle}.json`)
.then(response => response.json())
.then(json => dispatch(receivePosts(postTitle, json))).
但是fetch().then() return的结果是一个函数吗?
2017年11月 9日 16:45 | # | 引用
june 说:
es6 里的解构
2017年12月 6日 17:23 | # | 引用
kj huang 说:
我有以下的疑问,求解答:
的确,redux thunk让我们从直接返回一个action object变成可以返回一个函数F。而在这个函数里我们可以做更多的事情,那这个函数F本身返回什么重要吗?有什么意义呢?
就比如阮老师的例子里
return fetch(`/some/API/${postTitle}.json`)
.then(response => response.json())
.then(json => dispatch(receivePosts(postTitle, json)));
};
这里把return去掉照样可以。
2017年12月 7日 08:20 | # | 引用
小冶 说:
@kj huang:
形成Promise链式调用 ,后面可以接着继续.then()
2017年12月11日 00:57 | # | 引用
小鱼儿 说:
阮老师您可否写篇redux-saga的文章,网上的资料看不太懂
2017年12月21日 14:38 | # | 引用
wulinjie122 说:
确实,redux很多东西不是那么好理解,对于前端开发需要有一定的经验,不然开发出来都是坑。
2018年1月18日 10:50 | # | 引用
wenwen 说:
阮老师,第一个fetchPosts 的例子是否少了一个"return".不然函数无法返回
2018年1月22日 15:18 | # | 引用
小棋童 说:
有个问题 我这个异步 第一个action 我不发 直接在异步请求之后 在发送 action 效果也同样能实现啊 不理解 第一个action存在的意义 求指教
2018年2月 1日 17:43 | # | 引用
nanshu 说:
大兄弟这本来就是编给团队约束用的,你小项目和个人项目随便你怎么写,直接setState就行
2018年4月15日 15:10 | # | 引用
Zark 说:
2018年4月20日 17:08 | # | 引用
react重度爱好者 说:
是啊,缺什么技能呢,大神帮我们梳理一下
2018年5月31日 09:28 | # | 引用
Stanny 说:
es 6 数组扩展运算符,把传入的参数合并成一个数组
2018年6月13日 18:23 | # | 引用
wei 说:
老师,第四节的applyMiddlewares多了个s吧?
2018年6月28日 16:19 | # | 引用
哈哈哈 说:
看看这个吧,老师将的就是怎么用的,没说其他的东西,
https://github.com/react-guide/redux-tutorial-cn
这个可以看看,入门更简单
2018年7月 5日 20:18 | # | 引用
John 说:
redux-logger 部分写法有变化, Since 3.0.0 redux-logger exports by default logger with default settings.
2018年8月 7日 15:56 | # | 引用
我什么都不知道 说:
我们到底缺了什么技能啊,求告知,我也看不懂了
2018年8月24日 17:11 | # | 引用
不愿意露面的路人 说:
我看了也不用太懂,不过我觉得看了就有用的,认真看,到时候要用的时候再来模仿着写一遍,做完了就知道为什么要这样做了。很多事情都是看的时候不知道为什么,必须直接是去实践一遍才知道这样做的意义。支持一下!!
2018年8月31日 14:59 | # | 引用
爱前端的假钢琴家 说:
我也一样,道行太浅????
2018年10月18日 15:15 | # | 引用
amanda 说:
不是道行问题, 是因为, 那个 , 本来都是一堆后端搞的 东西
2018年10月26日 16:24 | # | 引用
猫先生 说:
对比之下,我觉得vue技术栈的东西用起来太舒服了
2018年11月 6日 16:10 | # | 引用
哆啦斯基 说:
感觉对异步的处理,redux确实没有vuex更容易上手
2019年3月 5日 17:56 | # | 引用
陈先生 说:
使用async和await后直接dispatch,那么还有必要使用中间件吗?
2019年5月27日 16:38 | # | 引用
陈武贤 说:
异步中间件是为了封装'异步进行时'和异步结束,和你说的那个什么await async没什么关系.
2019年6月21日 11:40 | # | 引用
admin 说:
“这里的fetchPosts就是 Action Creator。”怎么理解。
2019年8月22日 11:03 | # | 引用
chris1299 说:
看不懂了,后面
2019年8月27日 08:06 | # | 引用
α&Ω 说:
不完备,不充分,反紧凑的框架,最搞笑的是因为自己弄出的多余的概念不断衍生出新的补丁概念,却没看清自己第一步的不完备细分就已经埋下了祸根。
2019年8月30日 03:15 | # | 引用
葛干 说:
第一个action只是把正在更新的状态写进状态管理中,如果不需要写入redux中,那么你这种写法也是可以的 但是如果多个组件需要持有正在更新的状态,或者你想把正在更新的状态写入redux中,则需要发送第一个action
2019年9月16日 11:29 | # | 引用
c9 说:
redux-promise那段以下代码少个括号
return fetch(`/some/API/${postTitle}.json`)
.then(response => {
type: 'FETCH_POSTS',
payload: response.json()
});
2019年10月18日 14:34 | # | 引用
吐槽大王 说:
如果没有facebook的大后台,react redux啥也不是,vue vuex用起来真是太舒服了,行云流水,不知道一个前端框架弄的这么复杂干嘛,晦涩难懂,用在项目中也是瑟瑟发抖,怎么把精力放在业务上呢。论后台霸霸的重要性,即使是个石头,也让你飞上天。
2020年5月 7日 17:10 | # | 引用
skylar 说:
多看几遍会深有体会
2020年8月 4日 20:10 | # | 引用
冰冰 说:
感觉用react写个东西特别麻烦,难学而且不好用,难道是我一个人这样感觉的吗? this.$store可以搞定的事情,非要写好几个文件。
2020年8月28日 16:24 | # | 引用
炳哲 说:
const fetchPosts = postTitle => (dispatch, getState) => {
dispatch(requestPosts(postTitle));
return fetch(`/some/API/${postTitle}.json`)
.then(response => response.json())
.then(json => dispatch(receivePosts(postTitle, json)));
};
};
最后一行多个大括号
2021年11月12日 14:42 | # | 引用
eagle1098 说:
我感觉Vue就是把React里面一些需要手动写的东西自动化了,所以写代码比较省力,但也正因如此,使用Vue没法对代码进行深入优化,而React则要灵活得多,更适合需要定制优化的大型项目。
2022年1月 4日 16:23 | # | 引用
white 说:
展开对象
2022年1月20日 16:20 | # | 引用