IMVC(同構 MVC)的前端實踐
導語
隨著 Backbone 等老牌框架的逐漸衰退,前端 MVC 發展緩慢,有逐漸被 MVVM/Flux 所取代的趨勢。
然而,縱觀近幾年的發展,可以發現一點,React/Vue 和 Redux/Vuex 是分別在 MVC 中的 View 層和 Model 層做了進一步發展。如果 MVC 中的 Controller 層也推進一步,將得到一種升級版的 MVC,我們稱之為 IMVC(同構 MVC)。
IMVC 可以實現一份代碼在服務端和瀏覽器端皆可運行,具備單頁應用和多頁應用的所有優勢,並且可以這兩種模式里通過配置項進行自由切換。配合 Node.js、Webpack、Babel 等基礎設施,我們可以得到相比之前更加完善的一種前端架構。
1、同構的概念和意義
1.1、isomorphic 是什麼?
isomorphic,讀作[?a?s?』m?:f?k],意思是:同形的,同構的。
維基百科對它的描述是:同構是在數學對象之間定義的一類映射,它能揭示出在這些對象的屬性或者操作之間存在的關係。若兩個數學結構之間存在同構映射,那麼這兩個結構叫做是同構的。一般來說,如果忽略掉同構的對象的屬性或操作的具體定義,單從結構上講,同構的對象是完全等價的。
同構,也被化用在物理、化學以及計算機等其他領域。
1.2、isomorphic javascript
isomorphic javascript(同構 js),是指一份 js 代碼,既然可以跑在瀏覽器端,也可以跑在服務端。
圖1
同構 js 的發展歷史,比 progressive web app 還要早很多。2009 年, node.js 問世,給予我們前後端統一語言的想像;更進一步的,前後端公用一套代碼,也不是不可能。
有一個網站 http://isomorphic.net,專門收集跟同構 js 相關的文章和項目。從裡面的文章列表來看,早在 2011 年的時候,業界已經開始探討同構 js,並認為這將是未來的趨勢。
可惜的是,同構 js 其實並沒有得到真正意義上的發展。因為,在 2011 年,node.js 和 ECMAScript 都不夠成熟,我們並沒有很好的基礎設施,去滿足同構的目標。
現在是 2017 年,情況已經有所不同。ECMAScript 2015 標準定案,提供了一個標準的模塊規範,前後端通用。儘管目前 node.js 和瀏覽器都沒有實現 ES2015 模塊標準,但是我們有 Babel 和 Webpack 等工具,可以提前享用新的語言特性帶來的便利。
2、同構的種類和層次
2.1、同構的種類
同構 js 有兩個種類:「內容同構」和「形式同構」。
其中,「內容同構」指服務端和瀏覽器端執行的代碼完全等價。比如:
function add(a, b) {
return a + b
}
不管在服務端還是瀏覽器端,add 函數都是一樣的。
而「形式同構」則不同,從原教旨主義的角度上看,它不是同構。因為,在瀏覽器端有一部分代碼永遠不會執行,而在服務端另一部分代碼永遠不會執行。比如:
function doSomething() {
if (isServer) {
// do something in server-side
} else if (isClient) {
// do something in client-side
}
}
在 npm 里,有很多 package 標榜自己是同構的,用的方式就是「形式同構」。如果不作特殊處理,「形式同構」可能會增加瀏覽器端載入的 js 代碼的體積。比如 React,它的 140+kb 的體積,是把只在服務端運行的代碼也包含了進去。
2.2、同構的層次
同構不是一個布爾值,true 或者 false;同構是一個光譜形態,可以在很小範圍里上實現同構,也可以在很大範圍里實現同構。
- function 層次:零碎的代碼片斷或者函數,支持同構。比如瀏覽器端和服務端都實現了 setTimeout 函數,比如 lodash/underscore 的工具函數都是同構的。
- feature 層次:在這個層次里的同構代碼,通常會承擔一定的業務職能。比如 React 和 Vue 都藉助 virtual-dom 實現了同構,它們是服務於 View 層的渲染;比如 Redux 和 Vuex 也是同構的,它們負責 Model 層的數據處理。
- framework 層次:在框架層面實現同構,它可能包含了所有層次的同構,需要精心處理支持同構和不支持同構的兩個部分,如何妥善地整合在一起。
我們今天所討論的 isomorphic-mvc(簡稱 IMVC),是在 framework 層次上實現同構。
3、同構的價值和作用
3.1、同構的價值
同構 js,不僅僅有抽象上的美感,它還有很多實用價值。
- SEO 友好:View 層在瀏覽器端和服務端都可以運行,意味著可以在服務端吐出 html,支持搜索引擎的抓取。
- 加快訪問體驗:服務端渲染可以加快瀏覽器端的首次訪問的渲染速度,而瀏覽器端渲染,可以加快用戶交互時的反饋速度。
- 代碼的可維護性:同構可以減少語言切換的成本,減小代碼的重複率,增加代碼的可維護性。
不使用同構方案,也可以用別的辦法實現前兩個的目標,但是別的辦法卻難以同時滿足三個目標。
3.2、同構如何加快訪問體驗
純瀏覽器端渲染的問題在於,頁面需要等待 js 載入完畢之後,才可見。
圖2 client-side renderging服務端渲染可以加速首次訪問的體驗,在 js 載入之前,頁面就渲染了首屏。但是,用戶只對首次載入有耐心,如果操作過程中,頻繁刷新頁面,也會帶給用戶緩慢的感覺。
圖3 SERVER-SIDE RENDERING同構渲染則可以得到兩種好處,在首次載入時用服務端渲染,在交互過程中則採取瀏覽器端渲染。
3.3、同構是未來的趨勢
從歷史發展的角度看,同構確實是未來的一大趨勢。
在 Web 開發的早期,採用的開發模式是:fat-server, thin-client
圖4前端只是薄薄的一層,負責一些表單驗證,DOM 操作和 JS 動畫。在這個階段,沒有「前端工程師」這個工種,服務端開發順便就把前端代碼給寫了。
在 Ajax 被發掘出來之後,Web 進入 2.0 時代,我們普遍推崇的模式是:thin-server, fat-client
圖5越來越多的業務邏輯,從服務端遷移到前端。開始有「前後端分離」的做法,前端希望服務端只提供 restful 介面和數據持久化。
但是在這個階段,做得不夠徹底。前端並沒有完全掌控渲染層,起碼 html 骨架需要服務端渲染,以及前端實現不了服務端渲染。
為了解決上述問題,我們正在進入下一個階段,這個階段所採取的模式是:shared, fat-server, fat-client。
圖6通過 node.js 運行時,前端完全掌控渲染層,並且實現渲染層的同構。既不犧牲服務端渲染的價值,也不放棄瀏覽器端渲染的便利。
這就是未來的趨勢。
4、同構的實現策略
要實現同構,首先要正視一點,全盤同構是沒有意義的。為什麼?
服務端和瀏覽器端畢竟是兩個不同的平台和環境,它們專註於解決不同的問題,有自身的特點,全盤同構就抹殺了它們固有的差異,也就無法發揮它們各自的優勢。
因而,我們只會在 client 和 server 有交集的部分實現同構。就是在服務端渲染 html 和在瀏覽器端復用 html 的整個過程里,實現同構。
我們採取的主要做法有兩個:1)能夠同構的代碼,直接復用;2)無法同構的代碼,封裝成形式同構。
舉幾個例子。
獲取 User-Agent 字元串。
圖7我們可以在服務端用 req.get(『user-agent』) 模擬出 navigator 全局對象,也可以提供一個 getUserAgent 的方法或函數。
獲取 Cookies。
圖8Cookies 處理在我們的場景里,存在快捷通道,因為我們只專註首次渲染的同構,其它的操作可以放在瀏覽器端二次渲染的時候再處理。
Cookies 的主要用途發生在 ajax 請求的時候,在瀏覽器端 ajax 請求可以設置為自動帶上 Cookies,所以只需要在服務端默默地在每個 ajax 請求頭裡補上 Cookies 即可。
Redirects 重定向處理
圖9重定向的場景比較複雜,起碼有三種情況:
- 服務端 302 重定向: res.redirect(xxx)
- 瀏覽器端 location 重定向:location.href = xxx 和 location.replace(xxx)
- 瀏覽器端 pushState 重定向:history.push(xxx) 和 history.replace(xxx)
我們需要封裝一個 redirect 函數,根據輸入的 url 和環境信息,選擇正確的重定向方式。
5、IMVC 架構
5.1、IMVC 的目標
IMVC 的目標是框架層面的同構,我們要求它必須實現以下功能
- 用法簡單,初學者也能快速上手
- 只維護一套 ES2015+ 的代碼
- 既是單頁應用,又是多頁應用(SPA + SSR)
- 可以部署到任意發布路徑 (Basename/RootPath)
- 一條命令啟動完備的開發環境
- 一條命令完成打包/部署過程
有些功能屬於運行時的,有些功能則只服務於開發環境。JavaScript 雖然是一門解釋型語言,但前端行業發展到現階段,它的開發模式已經變得非常豐富,既可以用最樸素的方式,一個記事本加上一個瀏覽器,也可以用一個 IDE 加上一系列開發、測試和部署流程的支持。
5.2、IMVC 的技術選型
- Router: create-app = history + path-to-regexp
- View: React = renderToDOM || renderToString
- Model: relite = redux-like library
- Ajax: isomorphic-fetch
理論上,IMVC 是一種架構思路,它並不限定我們使用哪些技術棧。不過,要使 IMVC 落地,總得做出選擇。上面就是我們當前選擇的技術棧,將來它們可能升級或者替換為其它技術。
5.3、為什麼不直接用 React 全家桶?
大家可能注意到,我們使用了許多 React 相關的技術,但卻不是所謂的 React 全家桶,原因如下:
- 目前的 React 全家桶其實是野生的,Facebook 並不用
- React-Router 的理念難以滿足要求
- Redux 適用於大型應用,而我們的主要場景是中小型
- 升級頻繁導致學習成本過高,需封裝一層更簡潔的 API
目前的全家桶,只是社區里的一些熱門庫的組合罷了。Facebook 真正用的全家桶是 react|flux|relay|graphql,甚至他們並不用 React 做服務端渲染,用的是 PHP。
我們認為 React-Router 的理念在同構上是錯誤的。它忽視了一個重大事實:服務端是 Router 路由驅動的,把 Router 和作為 View 的 React 捆綁起來,View 已經實例化了,Router 怎麼再載入 Controller 或者非同步請求數據呢?
從函數式編程的角度看,React 推崇純組件,需要隔離副作用,而 Router 則是副作用來源,將兩者混合在一起,是一種污染。另外,Router 並不是 UI,卻被寫成 JSX 組件的形式,這也是有待商榷的。
所以,即便是當前最新版的 React-Router-v4,實現同構渲染時,做法也複雜而臃腫,服務端和瀏覽器端各有一個路由表和發 ajax 請求的邏輯。
至於 Redux,其作者也已在公開場合表示:「你可能不需要 Redux」。在引入 redux 時,我們得先反思一下引入的必要性。
毫無疑問,Redux 的模式是優秀的,結構清晰,易維護。然而同時它也是繁瑣的,實現一個功能,你可能得跨文件夾地操作數個文件,才能完成。這些代價所帶來的顯著好處,要在 app 複雜到一定程度時,才能真正體會。其它模式里,app 複雜到一定程度後,就難以維護了;而 Redux 的可維護性還依然堅挺,這就是其價值所在。(值得一提的是,基於 redux 再封裝一層簡化的 API,我認為這很可能是錯誤的做法。Redux 的源碼很簡潔,意圖也很明確,要簡化固然也是可以的,但它為什麼自己不去做?它是不是刻意這樣設計呢?你的封裝是否損害了它的設計目的呢?)
在使用 Redux 之前要考慮的是,我們 web-app 屬於大型應用的範疇嗎?
前端領域日新月異,框架和庫的頻繁升級讓開發者應接不暇。我們需要根據自身的需求,進行二次封裝,得到一組更簡潔的 API,將部分複雜度隱藏起來,以降低學習成本。
5.4、用 create-app 代替 react-router
create-app 是我們為了同構而實現的一個 library,它由下面三部分組成:
- history: react-router 依賴的底層庫
- path-to-regexp: expressjs 依賴的底層庫
- Controller:在 View(React) 層和 Model 層之外實現 Controller 層
create-app 復用 React-Router 的依賴 history.js,用以在瀏覽器端管理 history 狀態;復用 expressjs 的 path-to-regexp,用以從 path pattern 中解析參數。
我們認為,React 和 Redux 分別對應 MVC 的 View 和 Model,它們都是同構的,我們需要的是實現 Controller 層的同構。
5.4.1、create-app 的同構理念
圖10create-app 實現同構的方式是:
- 輸入 url,router 根據 url 的格式,匹配出對應的 controller 模塊
- 調用 module-loader 載入 controller 模塊,拿到 Controller 類
- View 和 Model 從屬於 Controller 類的屬性
- new Controller(location, context) 得到 controller 實例
- 調用 controller.init 方法,該方法必須返回 view 的實例
- 調用 view-engine 將 view 的實例根據環境渲染成 html 或者 dom 或者 native-ui 等
上述過程在服務端和瀏覽器端都保持一致。
5.4.2、create-app 的配置理念
服務端和瀏覽器端載入模塊的方式不同,服務端是同步載入,而瀏覽器端則是非同步載入;它們的 view-engine 也是不同的。如何處理這些不一致?
答案是配置。
const app = createApp({
type: 『createHistory』,
container: 『#root』,
context: {
isClient: true|false,
isServer: false|true,
…injectFeatures
},
loader: webpackLoader|commonjsLoader,
routes: routes,
viewEngine: ReactDOM|ReactDOMServer,
})
app.start() || app.render(url, context)
服務端和瀏覽器端分別有自己的入口文件:client-entry.js 和 server.entry.js。我們只需提供不同的配置即可。
在服務端,載入 controller 模塊的方式是 commonjsLoader;在瀏覽器端,載入 controller 模塊的方式則為 webpackLoader。
在服務端和瀏覽器端,view-engine 也被配置為不同的 ReactDOM 和 ReactDOMServer。
每個 controller 實例,都有 context 參數,它也是來自配置。通過這種方式,我們可以在運行時注入不同的平台特性。這樣既分割了代碼,又實現了形式同構。
5.4.3、create-app 的服務端渲染
我們認為,簡潔的,才是正確的。create-app 實現服務端渲染的代碼如下:
const app = createApp(serverSettings)
router.get(『*』, async (req, res, next) => {
try {
const { content } = await app.render(req.url, serverContext)
res.render(『layout』, { content })
} catch(error) {
next(error)
}
})
沒有多餘的信息,也沒有多餘的代碼,輸入一個 url 和 context,返回具有真實數據 html 字元串。
5.4.4、create-app 的扁平化路由理念
React-Router 支持並鼓勵嵌套路由,其價值存疑。它增加了代碼的閱讀成本,以及各個路由模塊之間的關係與 UI(React 組件)的嵌套耦合在一起,並不靈活。
使用扁平化路由,可以使代碼解耦,容易閱讀,並且更為靈活。因為,UI 之間的復用,可以通過 React 組件的直接嵌套來實現。
基於路由嵌套關係來複用 UI,容易遇上一個尷尬場景:恰好只有一個頁面不需要共享頭部,而頭部卻不在它的控制範疇內。
// routes
export default [{
path: 『/demo』,
controller: require(『./home/controller』)
}, {
path: 『/demo/list』,
controller: require(『./list/controller』)
}, {
path: 『/demo/detail』,
controller: require(『./detail/controller』)
}]
如你所見,我們的 path 對應的並不是 component,而是 controller。通過新增 controller 層,我們可以實現在 view 層的 component 實例化之前,就藉助 controller 獲取首屏數據。
next.js 也是一個同構框架,它本質上是簡化版的 IMVC,只不過它的 C 層非常薄,以至於直接掛在 View 組件的靜態方法里。它的路由配置目前是基於 View 的文件名,其 Controller 層是 View.getInitialProps 靜態方法,只服務於獲取初始化 props。
這一層太薄了,它其實可以更為豐富,比如提供 fetch 方法,內置環境判斷,支持 jsonp,支持 mock 數據,支持超時處理等特性,比如自動綁定 store 到 view,比如提供更為豐富的生命周期 pageWillLeave(頁面將跳轉到其他路徑) 和 windowWillUnload (窗口即將關閉)等。
總而言之,副作用不可能被消滅,只能被隔離,如今 View 和 Model 都是 pure-function 和 immutabel-data 的無副作用模式,總得有角色承擔處理副作用的職能。新的抽象層 Controller 應運而生。
5.4.5、create-app 的目錄結構
├── src // 源代碼目錄
│ ├── app-demo // demo目錄
│ ├── app-abcd // 項目 abcd 平台目錄
│ │ ├── components // 項目共享組件
│ │ ├── shared // 項目共享方法
│ │ └── BaseController // 繼承基類 Controller 的項目層 Controller
│ │ ├── home // 具體頁面
│ │ │ ├── controller.js // 控制器
│ │ │ ├── model.js // 模型
│ │ │ └── view.js // 視圖
│ │ ├── * // 其他頁面
│ │ └── routes.js // abc 項目扁平化路由
│ ├── app-* // 其他項目
│ ├── components // 全局共享組件
│ ├── shared // 全局共享文件
│ │ └── BaseController // 基類 Controller
│ ├── index.js // 全局 js 入口
│ └── routes.js // 全局扁平化路由
├── static // 源碼 build 的目標靜態文件夾
如上所示,create-app 推崇的目錄結構跟 redux 非常不同。它不是按照抽象的職能 actionCreator|actionType|reducers|middleware|container 來安排的,它是基於 page 頁面來劃分的,每個頁面都有三個組成部分:controller,model 和 view。
用 routes 路由表,將 page 串起來。
create-app 採取了「整站 SPA」 的模式,全局只有一個入口文件,index.js。src 目錄下的文件都所有項目共享的框架層代碼,各個項目自身的業務代碼則在 app-xxx 的文件夾下。
這種設計的目的是為了降低遷移成本,靈活切分和合併各個項目。
- 當某個項目處於萌芽階段,它可以依附在另一個項目的 git 倉庫里,使用它現成的基礎設施進行快速開發。
- 當兩個項目足夠複雜,值得分割為兩個項目時,它們可以分割為兩個項目,各自將對方的文件夾整個刪除即可。
- 當兩個項目要合併,將它們放到同一 git 倉庫的不同 app-xxx 里即可。
- 我們使用本地路由表 routes.js 和 nginx 配置協調 url 的訪問規則
每個 page 的 controller.js,model.js 和 view.js 以及它們的私有依賴,將會被單獨打包到一個文件,只有匹配 url 成功時,才會按需載入。保證多項目並存不會帶來 js 體積的膨脹。
5.5、controller 的基本模式
我們新增了 controller 這個抽象層,它將承擔連接 Model,View,History,LocalStorage,Server 等對象的職能。
Controller 被設計為 OOP 編程範式的一個 class,主要目的就是為了讓它承受副作用,以便 View 和 Model 層保持函數式的純粹。
Controller 的基本模式如下:
class MyController extends BaseController {
requireLogin = true // 是否依賴登陸態,BaseController 里自動處理
View = View // 視圖
initialState = { count: 0 } // model 初始狀態initialState
actions = actions // model 狀態變化的函數集合 actions
handleIncre = () => { // 事件處理器,自動收集起來,傳遞給 View 組件
let { history, store, fetch, location, context } = this // 功能分層
let { INCREMENT } = store.actions
INCREMENT() // 調用 action,更新 state, view 隨之自動更新
}
async shouldComponentCreate() {} // 在這裡鑒權,return false
async componentWillCreate() {} // 在這裡 fetch 首屏數據
componentDidMount() {} // 在這裡 fetch 非首屏數據
pageWillLeave() {} // 在這裡執行路由跳轉離開前的邏輯
windowWillUnload() {} // 在這裡執行頁面關閉前的邏輯
}
我們將所有職能對象放到了 controller 的屬性中,開發者只需提供相應的配置和定義,在豐富的生命周期里按需調用相關方法即可。
它的結構和模式跟 vue 和微信小程序有點相似。
5.6、redux 的簡化版 relite
儘管作為中小型應用的架構,我們不使用 Redux,但是對於 Redux 中的優秀理念,還是可以吸收進來。
所以,我們實現了一個簡化版的 redux,叫做 relite。
- actionType, actionCreator, reducer 合併
- 自動 bindActionCreators,內置非同步 action 的支持
let EXEC_BY = (state, input) => {
let value = parseFloat(input, 10)
return isNaN(value) ? state : {
…state,
count: state.count + value
}
}
let EXEC_ASYNC = async (state, input) => {
await delay(1000)
return EXEC_BY(state, input)
}
let store = createStore(
{ EXEC_BY, EXEC_ASYNC },
{ count: 0 }
)
我們希望得到的是 redux 的兩個核心:1)pure-function,2)immutable-data。
所以 action 函數被設計為純函數,它的函數名就是 redux 的 action-type,它的函數體就是 redux 的 reducer,它的第一個參數是當前的 state,它的第二個參數是 redux 的 actionCreator 攜帶的數據。並且,relite 內置了 redux-promise 和 redux-thunk 的功能,開發者可以使用 async/await 語法,實現非同步 action。
relite 也要求 state 儘可能是 immutable,並且可以通過額外的 recorder 插件,實現 time-travel 的功能。
5.7、Isomorphic-MVC 的工程化設施
上面講述了 IMVC 在運行時里的一些功能和特點,下面簡單地描述一下 IMVC 的工程化設施。我們採用了:
- node.js 運行時,npm 包管理
- expressjs 服務端框架
- babel 編譯 ES2015+ 代碼到 ES5
- webpack 打包和壓縮源碼
- standard.js 檢查代碼規範
- prettier.js + git-hook 代碼自動美化排版
- mocha 單元測試
5.7.1、如何實現代碼實時熱更新?
目標:一個命令啟動開發環境,修改代碼不需重啟進程
做法:一個 webpack 服務於 client,另一個 webpack 服務於 server
client: express + webpack-dev-middleware 在內存里編譯
server: memory-fs + webpack + vm-module
服務端的 webpack 編譯到內存模擬的文件系統,再用 node.js 內置的虛擬機模塊執行後得到新的模塊
5.7.2、如何處理 CSS 按需載入?
問題根源:瀏覽器只在 dom-ready 之前會等待 css 資源載入後再渲染頁面
問題描述:當單頁跳轉到另一個 url,css 資源還沒載入完,頁面顯示成混亂布局
處理辦法:將 css 視為預載入的 ajax 數據,以 style 標籤的形式按需引入
優化策略:用 context 緩存預載入數據,避免重複載入
5.7.3、如何實現代碼切割、按需載入?
- 不使用 webpack-only 的語法 require.ensure
- 在瀏覽器里 require 被編譯為載入函數,非同步載入
- 在 node.js 里 require 是同步載入
// webpack.config.js
{
test: /controller.jsx?$/,
loader: 『bundle-loader』,
query: {
lazy: true,
name: 『[1]-[folder]』,
regExp: /[/]app-([^/]+)[/]/.source
},
exclude: /node_modules/
}
5.7.4、如何處理靜態資源的版本管理?
- 以代碼的 hash 為文件名,增量發布
- 用 webpack.stats.plugin.js 生成靜態資源表
- express 使用 stats.json 的數據渲染頁面
// webpack.config.js
output = {
path: outputPath,
filename: 『[name]-[hash:6].js』,
chunkFilename: 『[name]-[chunkhash:6].js』
}
5.7.5、如何管理命令行任務?
- 使用 npm-scripts 在 package.json 里完成 git、webpack、test、prettier 等任務的串並聯邏輯
- npm start 啟動完整的開發環境
- npm run start:client 啟動不帶服務端渲染的開發環境
- npm run build 啟動自動化編譯,構建與壓縮部署的任務
- npm run build:show-prod 用 webpack-bundle-analyzer 可視化查看編譯結果
6、結語
IMVC 經過實踐和摸索,已被證明是一種有效的模式,它以較高的完成度實現了真正意義上的同構。不再局限於紙面上的理念描述,而是一個可以落地的方案,並且實際地提升了開發體驗和效率。後續我們將繼續往這個方向探索。
[作者簡介] 古映傑,攜程度假研發部前端和 node.js 架構負責人。開源庫 react-lite 作者。熱衷於研究如何讓前沿技術落地,提高前端工程師的實際生產力和編程體驗。目前致力於推廣同構 MVC 的前端架構模型。業餘時間喜歡玩《守望先鋒》。本文來自古映傑在「攜程技術沙龍——新一代前端技術實踐」上的分享。
沒看夠?更多來自攜程技術人的一手乾貨,歡迎搜索關注「攜程技術中心」微信公號哦~
推薦閱讀:
※基於TableStore構建簡易海量Topic消息隊列
※CODING 代碼託管架構升級之路
※雙十一絲般順滑體驗背後:阿里雲洛神網路虛擬化系統揭秘
※走過第六個雙11,雙11阿里雲技術負責人楊旭說:大考亦從容