早几年前端还处于刀耕火种、JQuery独树一帜的时代,先后端代码的耦合度很高,一个web页面文件的代码多是这样的:php
这意味着后端的工程师每每得负责一部分修改HTML、编写脚本的工做,而前端开发者也得了解页面上存在的服务端代码含义。css
有时候某处页面逻辑的变更,鉴于代码的混搭,可能都不肯定应该请后端仍是前端来改动(即便他们都能处理)。前端
前端框架热潮node
有句俗话说的好——“人啊,要是擅于开口‘关我屁事’和‘关你屁事’这俩句,能够节省人生中的大部分时间”。react
随着这两年被 angular 牵头带起的各类前端MV*框架的风靡,后端能够毋须再于静态页面耗费心思,只须要专心开发数据接口供前端使用便可。得益于此,先后端终于能够安心地互相道一声“关我屁事”或“关你屁事”了。webpack
以 avalon 为例,前端只须要在页面加载时发送个ajax请求取得数据绑定到vm,而后作view层渲染便可:git
var vm = avalon.define({ $id: "wrap", list: [] }); fetch('data/list.php') //向后端接口发出请求 .then(res => res.json()) .then(json => { vm.list = json; //数据注入vm avalon.scan(); //渲染view层 });
静态页面的代码也由前端一手掌握,本来服务端的代码换成了 avalaon 的专用属性与插值表达式:程序员
<ul ms-controller="wrap"> <li ms-repeat="list">{{el.name}}</li> </ul>
先后端代码隔离的形式大大提高了项目的可维护性和开发效率,已经成为一种web开发的主流模式。它解放了后端程序员的双手,也将更多的控制权转移给前端人员(固然前端也所以须要多学习一些框架知识)。github
弊端web
先后端隔离的模式虽然给开发带来了便利,但相比水乳交融的旧模式,页面首屏的数据须要在加载的时候向服务端发去请求才能取得,多了请求等候的时间(RTT)。
这意味着用户访问页面的时候,这段“等待后端返回数据”的时延会处于白屏状态,若是用户网速差,那么这段首屏等候时间会是很糟糕的体验。
固然拉到数据后,还得作 view 层渲染(客户端引擎的处理仍是很快的,忽略渲染的时间),这又依赖于框架自己,即框架要先被下载下来才能处理这些视图渲染操做。那么好家伙,一个 angular.min.js 就达到了 120 多KB,用着渣信号的用户得多等上一两秒来下载它。
这么看来,单纯先后端隔离的形式存在首屏时间较长的问题,除非将来平均网速达到上G/s,否则都是不理想的体验。
另外使用前端框架的页面也不利于SEO,其实应该说不利于国内这些渣搜索引擎的SEO,谷歌早已能从内存中去抓数据(客户端渲染后的DOM数据)。
so 怎么办?相信不少朋友猜到了——用 node 来助阵。
直出和同构
直出说白了其实就是“服务端渲染并输出”,跟起初咱们说起的先后端水乳交融的开发模式基本相似,只是后端语言咱们换成了 node 。
09年开始冒头的 node 如今成了当红炸子鸡,包含阿里、腾讯在内的各大公司都普遍地把 node 用到项目上,先后端整而为一,若是 node 的特性适用于你的项目,那么何乐而不为呢。
咱们在这边也说起了一个“同构”的概念,即先后端(这里的“后端”指的是直出端,数据接口不必定由node开发)使用同一套代码方案,方便维护。
当前 node 在服务端有着许多主流抑或非主流的框架,包括 express、koa、thinkjs 等,可以较快上手,利用各类中间件得以进行敏捷开发。
另外诸如 ejs、jade 这样的渲染模板能让咱们轻松地把首屏内容(数据或渲染好的DOM树)注入页面中。
这样用户访问到的即是已经带有首屏内容的页面,大大下降了等候时间,提高了体验。
示例
在这里咱们以 koa + ejs + React 的服务端渲染为例,来看看一个简单的“直出”方案是怎样实现的。该示例也能够在个人github上下载到。
项目的目录结构以下:
+---data //模拟数据接口,放了一个.json文件 +---dist //文件构建后(gulp/webpack)存放处 | +---css | | +---common | | \---page | +---js | | +---component | | \---page | \---views | +---common | \---home +---modules //一些自行封装的通用业务模块 +---routes //路由配置 \---src //未构建的文件夹 +---css | +---common | +---component | \---page +---js | +---component //React组件 | \---page //页面入口文件 \---views //ejs模板 +---common \---home
1. node 端 jsx 解析处理
node 端是不会本身识别 React 的 jsx 语法的,故咱们须要在项目文件中引入 node-jsx ,即便如今能够安装 babel-cli 后(并添加预设)使用 babel-node 命令替代 node,但后者用起来总会出问题,故暂时仍是采纳 node-jsx 方案:
//app.js require('node-jsx').install({ //让node端能解析jsx extension: '.js' }); var fs = require('fs'), koa = require('koa'), compress = require('koa-compress'), render = require('koa-ejs'), mime = require('mime-types'), r_home = require('./routes/home'), limit = require('koa-better-ratelimit'), getData = require('./modules/getData'); var app = koa(); app.use(limit({ duration: 1000*10 , max: 500, accessLimited : "您的请求太过频繁,请稍后重试"}) ); app.use(compress({ threshold: 50, flush: require('zlib').Z_SYNC_FLUSH })); render(app, { //ejs渲染配置 root: './dist/views', layout: false , viewExt: 'ejs', cache: false, debug: true }); getData(app); //首页路由 r_home(app); app.use(function*(next){ var p = this.path; this.type = mime.lookup(p); this.body = fs.createReadStream('.'+p); }); app.listen(3300);
2. 首页路由('./routes/home')配置
var router = require('koa-router'), getHost = require('../modules/getHost'), apiRouter = new router(); var React = require('react/lib/ReactElement'), ReactDOMServer = require('react-dom/server'); var List = React.createFactory(require('../dist/js/component/List')); module.exports = function (app) { var data = this.getDataSync('../data/names.json'), //取首屏数据 json = JSON.parse(data); var lis = json.map(function(item, i){ return ( <li>{item.name}</li> ) }), props = {color: 'red'}; apiRouter.get('/', function *() { //首页 yield this.render('home/index', { title: "serverRender", syncData: { names: json, //将取到的首屏数据注入ejs模板 props: props }, reactHtml: ReactDOMServer.renderToString(List(props, lis)), dirpath: getHost(this) }); }); app.use(apiRouter.routes()); };
注意这里咱们使用了 ReactDOMServer.renderToString 来渲染 React 组件为纯 HTML 字符串,注意 List(props, lis) ,咱们还传入了 props 和 children。
其在 ejs 模板中的应用为:
<div class="wrap" id="wrap"><%-reactHtml%></div>
就这么简单地完成了服务端渲染的处理,但还有一处问题,若是组件中绑定了事件,客户端不会感知。
因此在客户端咱们也须要再作一次与服务端一致的渲染操做,鉴于服务端生成的DOM会被打上 data-react-id 标志,故在客户端渲染的话,react 会经过该标志位的对比来避免冗余的render,并绑定上相应的事件。
这也是咱们把所要注入组件中的数据(syncData)传入 ejs 的缘由,咱们将把它做为客户端的一个全局变量来使用,方便客户端挂载组件的时候用上:
ejs上注入直出数据:
<script> syncData = JSON.parse('<%- JSON.stringify(syncData) %>'); </script>
页面入口文件(js/page/home.js)挂载组件:
import React from 'react'; import ReactDOM from 'react-dom'; var List = require('../component/List'); var lis = syncData.names.map(function(item, i){ return ( <li>{item.name}</li> ) }); ReactDOM.render( <List {...syncData.props}> {lis} </List>, document.getElementById('wrap') );
3. 辅助工具
为了玩鲜,在部分模块里写了 es2015 的语法,而后使用 babel 来作转换处理,在 gulp 和 webpack 中都有使用到,具体可参考它们的配置。
另外鉴于服务端对 es2015 的特性支持不完整,配合 babel-core/register 或者使用 babel-node 命令都存在兼容问题,故针对全部须要在服务端引入到的模块(好比React组件),在koa运行前先作gulp处理转为es5(这些构建模块仅在服务端会用到,客户端走webpack直接引用未转换模块便可)。
ejs文件中样式或脚本的内联处理我使用了本身开发的 gulp-embed ,有兴趣的朋友能够玩一玩。
4. issue
说实话 React 的服务端渲染处理总体开发是没问题的,就是开发体验不够好,主要缘由仍是各方面对 es2015 支持不到位致使的。
虽然在服务端运行前,咱们在gulp中使用babel对相关模块进行转换,但像 export default XXX 这样的语法转换后仍是没法被服务端支持,只能降级写为 module.exports = XXX。但这么写,在其它模块就无法 import XXX from 'X' 了(改成 require('X')代替),总之不爽快。只能期待后续 node(其实应该说V8) 再迭代一些版本能更好地支持 es2015 的特性。
另外若是 React 组件涉及列表项,常规咱们会加上 key 的props特性来提高渲染效率,但即便先后端传入相同的key值,最终 React 渲染出来的 key 值是不一致的,会致使客户端挂载组件时再作一次渲染处理。
对于这点我我的建议是,若是是静态的列表,那么统一都不加 key ,若是是动态的,那么就加吧,客户端再渲染一遍感受也没多大点事。(或者你有更好方案请留言哈~)
5. 其它
有时候服务端引入的模块里面,有些东西是仅仅须要在客户端使用到的,咱们以这个示例中的组件 component/List 为例,里面的样式文件
require('css/component/List');
不该当在服务端执行的时候使用到,但鉴于同构,先后端用的一套东西,这个怎么解决呢?其实很好办,经过 window 对象来判断便可(只要没有什么中间件给你在服务端也加了window接口):
var isNode = typeof window === 'undefined'; if(!isNode){ require('css/component/List'); }
不过请注意,这里我经过 webpack 把组件的样式也打包进了客户端的页面入口文件,其实不稳当。由于经过直出,页面在响应的时候就已经把组件的DOM树都先显示出来了,但这个时候是尚未取到样式的(样式打包到入口脚本了),须要等到入口脚本加载的时候才能看到正确的样式,这个过程会有一个闪动的过程,是种不舒服的体验。
因此走直出的话,建议把首屏的样式抽离出来内联到头部去。
唠唠磕磕就说了这么多,欢迎讨论交流,共勉~