隨着 Backbone 等老牌框架的逐漸衰退,前端 MVC 發展緩慢,有逐漸被 MVVM/Flux 所取代的趨勢。javascript
然而,縱觀近幾年的發展,能夠發現一點,React/Vue 和 Redux/Vuex 是分別在 MVC 中的 View 層和 Model 層作了進一步發展。若是 MVC 中的 Controller 層也推動一步,將獲得一種升級版的 MVC,咱們稱之爲 IMVC(同構 MVC)。css
IMVC 能夠實現一份代碼在服務端和瀏覽器端皆可運行,具有單頁應用和多頁應用的全部優點,而且能夠這兩種模式裏經過配置項進行自由切換。配合 Node.js、Webpack、Babel 等基礎設施,咱們能夠獲得相比以前更加完善的一種前端架構。html
isomorphic,讀做[ˌaɪsə'mɔ:fɪk],意思是:同形的,同構的。前端
維基百科對它的描述是:同構是在數學對象之間定義的一類映射,它能揭示出在這些對象的屬性或者操做之間存在的關係。若兩個數學結構之間存在同構映射,那麼這兩個結構叫作是同構的。通常來講,若是忽略掉同構的對象的屬性或操做的具體定義,單從結構上講,同構的對象是徹底等價的。vue
同構,也被化用在物理、化學以及計算機等其餘領域。java
isomorphic javascript(同構 js),是指一份 js 代碼,既然能夠跑在瀏覽器端,也能夠跑在服務端。node
圖片來源:www.slideshare.net/spikebrehm/…react
同構 js 的發展歷史,比 progressive web app 還要早不少。2009 年, node.js 問世,給予咱們先後端統一語言的想象;更進一步的,先後端公用一套代碼,也不是不可能。webpack
有一個網站 isomorphic.net,專門收集跟同構 js 相關的文章和項目。從裏面的文章列表來看,早在 2011 年的時候,業界已經開始探討同構 js,並認爲這將是將來的趨勢。nginx
惋惜的是,同構 js 其實並無獲得真正意義上的發展。由於,在 2011 年,node.js 和 ECMAScript 都不夠成熟,咱們並無很好的基礎設施,去知足同構的目標。
如今是 2017 年,狀況已經有所不一樣。ECMAScript 2015 標準定案,提供了一個標準的模塊規範,先後端通用。儘管目前 node.js 和瀏覽器都沒有實現 ES2015 模塊標準,可是咱們有 Babel 和 Webpack 等工具,能夠提早享用新的語言特性帶來的便利。
同構 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 的體積,是把只在服務端運行的代碼也包含了進去。
同構不是一個布爾值,true 或者 false;同構是一個光譜形態,能夠在很小範圍裏上實現同構,也能夠在很大範圍裏實現同構。
function 層次:零碎的代碼片段或者函數,支持同構。好比瀏覽器端和服務端都實現了 setTimeout 函數,好比 lodash/underscore 的工具函數都是同構的。
feature 層次:在這個層次裏的同構代碼,一般會承擔必定的業務職能。好比 React 和 Vue 都藉助 virtual-dom 實現了同構,它們是服務於 View 層的渲染;好比 Redux 和 Vuex 也是同構的,它們負責 Model 層的數據處理。
framework 層次:在框架層面實現同構,它可能包含了全部層次的同構,須要精心處理支持同構和不支持同構的兩個部分,如何妥善地整合在一塊兒。
咱們今天所討論的 isomorphic-mvc(簡稱 IMVC),是在 framework 層次上實現同構。
同構 js,不只僅有抽象上的美感,它還有不少實用價值。
SEO 友好:View 層在瀏覽器端和服務端均可以運行,意味着能夠在服務端吐出 html,支持搜索引擎的抓取。
加快訪問體驗:服務端渲染能夠加快瀏覽器端的首次訪問的渲染速度,而瀏覽器端渲染,能夠加快用戶交互時的反饋速度。
代碼的可維護性:同構能夠減小語言切換的成本,減少代碼的重複率,增長代碼的可維護性。
不使用同構方案,也能夠用別的辦法實現前兩個的目標,可是別的辦法卻難以同時知足三個目標。
純瀏覽器端渲染的問題在於,頁面須要等待 js 加載完畢以後,纔可見。
client-side renderging
圖片來源:www.slideshare.net/spikebrehm/…
服務端渲染能夠加速首次訪問的體驗,在 js 加載以前,頁面就渲染了首屏。可是,用戶只對首次加載有耐心,若是操做過程當中,頻繁刷新頁面,也會帶給用戶緩慢的感受。
SERVER-SIDE RENDERING
圖片來源:www.slideshare.net/spikebrehm/…
同構渲染則能夠獲得兩種好處,在首次加載時用服務端渲染,在交互過程當中則採起瀏覽器端渲染。
從歷史發展的角度看,同構確實是將來的一大趨勢。
在 Web 開發的早期,採用的開發模式是:fat-server, thin-client
圖片來源:www.slideshare.net/spikebrehm/…
前端只是薄薄的一層,負責一些表單驗證,DOM 操做和 JS 動畫。在這個階段,沒有「前端工程師」這個工種,服務端開發順便就把前端代碼給寫了。
在 Ajax 被髮掘出來以後,Web 進入 2.0 時代,咱們廣泛推崇的模式是:thin-server, fat-client
圖片來源:www.slideshare.net/spikebrehm/…
愈來愈多的業務邏輯,從服務端遷移到前端。開始有「先後端分離」的作法,前端但願服務端只提供 restful 接口和數據持久化。
可是在這個階段,作得不夠完全。前端並無徹底掌控渲染層,起碼 html 骨架須要服務端渲染,以及前端實現不了服務端渲染。
爲了解決上述問題,咱們正在進入下一個階段,這個階段所採起的模式是:shared, fat-server, fat-client
圖片來源:www.slideshare.net/spikebrehm/…
經過 node.js 運行時,前端徹底掌控渲染層,而且實現渲染層的同構。既不犧牲服務端渲染的價值,也不放棄瀏覽器端渲染的便利。
這就是將來的趨勢。
要實現同構,首先要正視一點,全盤同構是沒有意義的。爲何?
服務端和瀏覽器端畢竟是兩個不一樣的平臺和環境,它們專一於解決不一樣的問題,有自身的特色,全盤同構就抹殺了它們固有的差別,也就沒法發揮它們各自的優點。
於是,咱們只會在 client 和 server 有交集的部分實現同構。就是在服務端渲染 html 和在瀏覽器端複用 html 的整個過程裏,實現同構。
咱們採起的主要作法有兩個:1)可以同構的代碼,直接複用;2)沒法同構的代碼,封裝成形式同構。
舉幾個例子。
獲取 User-Agent 字符串。
圖片來源:www.slideshare.net/spikebrehm/…
咱們能夠在服務端用 req.get('user-agent')
模擬出 navigator 全局對象,也能夠提供一個 getUserAgent
的方法或函數。
獲取 Cookies。
圖片來源:www.slideshare.net/spikebrehm/…
Cookies 處理在咱們的場景裏,存在快捷通道,由於咱們只專一首次渲染的同構,其它的操做能夠放在瀏覽器端二次渲染的時候再處理。
Cookies 的主要用途發生在 ajax 請求的時候,在瀏覽器端 ajax 請求能夠設置爲自動帶上 Cookies,因此只須要在服務端默默地在每一個 ajax 請求頭裏補上 Cookies 便可。
Redirects 重定向處理
圖片來源:www.slideshare.net/spikebrehm/…
重定向的場景比較複雜,起碼有三種狀況:
res.redirect(xxx)
location.href = xxx
和 location.replace(xxx)
history.push(xxx)
和 history.replace(xxx)
咱們須要封裝一個 redirect 函數,根據輸入的 url 和環境信息,選擇正確的重定向方式。
IMVC 的目標是框架層面的同構,咱們要求它必須實現如下功能
有些功能屬於運行時的,有些功能則只服務於開發環境。JavaScript 雖然是一門解釋型語言,但前端行業發展到現階段,它的開發模式已經變得很是豐富,既能夠用最樸素的方式,一個記事本加上一個瀏覽器,也能夠用一個 IDE 加上一系列開發、測試和部署流程的支持。
理論上,IMVC 是一種架構思路,它並不限定咱們使用哪些技術棧。不過,要使 IMVC 落地,總得作出選擇。上面就是咱們當前選擇的技術棧,未來它們可能升級或者替換爲其它技術。
你們可能注意到,咱們使用了許多 React 相關的技術,但卻不是所謂的 React 全家桶
,緣由以下:
目前的全家桶,只是社區裏的一些熱門庫的組合罷了。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,將部分複雜度隱藏起來,以下降學習成本。
create-app
是咱們爲了同構而實現的一個 library
,它由下面三部分組成:
create-app
複用 React-Router
的依賴 history.js
,用以在瀏覽器端管理 history 狀態;複用 expressjs
的 path-to-regexp
,用以從 path pattern
中解析參數。
咱們認爲,React
和 Redux
分別對應 MVC
的 View
和 Model
,它們都是同構的,咱們須要的是實現 Controller
層的同構。
create-app
實現同構的方式是:
new Controller(location, context)
獲得 controller 實例controller.init
方法,該方法必須返回 view 的實例上述過程在服務端和瀏覽器端都保持一致。
服務端和瀏覽器端加載模塊的方式不一樣,服務端是同步加載,而瀏覽器端則是異步加載;它們的 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 參數,它也是來自配置。經過這種方式,咱們能夠在運行時注入不一樣的平臺特性。這樣既分割了代碼,又實現了形式同構。
咱們認爲,簡潔的,纔是正確的。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 字符串。
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 應運而生。
├── 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
的文件夾下。
這種設計的目的是爲了下降遷移成本,靈活切分和合並各個項目。
app-xxx
裏便可。每一個 page 的 controller.js,model.js 和 view.js 以及它們的私有依賴,將會被單獨打包到一個文件,只有匹配 url 成功時,纔會按需加載。保證多項目並存不會帶來 js 體積的膨脹。
咱們新增了 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 和微信小程序有點類似。
儘管做爲中小型應用的架構,咱們不使用 Redux,可是對於 Redux 中的優秀理念,仍是能夠吸取進來。
因此,咱們實現了一個簡化版的 redux,叫作 relite。
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
的功能。能夠查看這個 demo 體驗實際效果。
上面講述了 IMVC 在運行時裏的一些功能和特色,下面簡單地描述一下 IMVC 的工程化設施。咱們採用了:
// webpack.config.js
{
test: /controller\.jsx?$/,
loader: 'bundle-loader',
query: {
lazy: true,
name: '[1]-[folder]',
regExp: /[\/\\]app-([^\/\\]+)[\/\\]/.source
},
exclude: /node_modules/
}複製代碼
// webpack.config.js
output = {
path: outputPath,
filename: '[name]-[hash:6].js',
chunkFilename: '[name]-[chunkhash:6].js'
}複製代碼
IMVC 通過實踐和摸索,已被證實是一種有效的模式,它以較高的完成度實現了真正意義上的同構。再也不侷限於紙面上的理念描述,而是一個能夠落地的方案,而且實際地提高了開發體驗和效率。後續咱們將繼續往這個方向探索。