下降首屏時間,「直出」是個什麼概念?

早幾年前端還處於刀耕火種、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);
View Code

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樹都先顯示出來了,但這個時候是尚未取到樣式的(樣式打包到入口腳本了),須要等到入口腳本加載的時候才能看到正確的樣式,這個過程會有一個閃動的過程,是種不舒服的體驗。

因此走直出的話,建議把首屏的樣式抽離出來內聯到頭部去。

 

嘮嘮磕磕就說了這麼多,歡迎討論交流,共勉~

donate

相關文章
相關標籤/搜索