随着 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 通过实践和摸索,已被证实是一种有效的模式,它以较高的完成度实现了真正意义上的同构。再也不局限于纸面上的理念描述,而是一个能够落地的方案,而且实际地提高了开发体验和效率。后续咱们将继续往这个方向探索。