【大前端以前後分離01】JS前端渲染VS服務器端渲染

前言

以前看了一篇文章:@Charlie.Zheng Web系統開發構架再思考-先後端的徹底分離,文中論述了爲什麼要先後分離,站在前端的角度來看,是頗有必要的;可是如何說服團隊使用前端渲染方案倒是一個現實問題,由於若是我是一個服務器端,我便會以爲不是頗有必要,爲何要先後分離,先後分離後遺留了什麼問題,如何解決,都得說清楚,這樣才能說服團隊使用前端渲染的方案,而最近我恰好遇到了框架選型的抉擇。javascript

來到新公司開始新項目了,須要作前端框架選型,由於以前內部同事採用的fis框架,而這邊又是使用的php,此次也就直接採用fis基於php的解決方案:php

http://oak.baidu.com/fis-pluscss

說句實話,fis這套框架作的不錯,可是若是使用php方案的話,我就須要蛋疼的在其中寫smarty模板,而後徹底按照規範走,雖然fis規範比較合理,也能夠接受,可是稍微深刻解後發現fis基於php的方案能夠歸納爲(咱們的框架用成這樣,不特指fis):html

服務器端渲染html所有圖給瀏覽器,再加載前端js處理邏輯

顯然,這個不是我要的,夢想中的工做方式是作到靜態html化,靜態html裝載js,使用json進行業務數據通訊,這就是一些朋友所謂的前端渲染了前端

JS渲染的鄙利

前端渲染會帶來不少好處:java

① 徹底釋放前端,運行不須要服務器;node

② 服務器端只提供接口數據服務,業務邏輯所有在前端,先後分離;react

③ 一些地方性能有所提高,好比服務器不須要解析index.html,直接返回便可;jquery

④ ......git

事實上以上的說法和優點皆沒有十足的說服力,根據上述因素,咱們知道了爲何咱們要採用js+json的方案,但這不表明應該採用。

好比不少朋友認爲先後分離可讓前端代碼更加清晰,這一說法我就十分不認同,若是前端代碼功力不夠,絕對能夠寫整天書,分離是必要條件,卻不是分離後前端就必定清晰,不然也不會有那麼多人呼籲模塊化、組件化;並且服務器端徹底能夠質疑這樣作的種種問題,好比:

① 前端模板解析對手機端的負擔,對手機電池產生更快的消耗;

前端渲染頁面內容不能被爬蟲識別,SEO等於沒有了;

③ 前端渲染現階段沒有完善的ABTesting方案;

④ 不能保證一個URL每次展現的內容一致,好比js分頁致使路由不一致;

⑤ ......

以上的問題,一些是難點,一些是痛點,選取前端渲染方案至少得有SEO解決方案,否則一切都是空談

因此有如此多的問題,前端憑什麼說服團隊使用前端渲染的方案,難道僅僅是咱們爽了,咱們以爲這樣好就能夠了嗎?

何況現狀是團隊中服務器端的同事資深的多,前端話語權不夠,這個時候須要用數聽說話,但未作調研也拿不出數據,沒有數據你憑什麼說服領導採用前端渲染方案?

爲何要採用前端渲染

最近兩年我卻找到了能夠說服本身採用前端渲染的緣由:

① 體驗更好

Hybrid內嵌只能用靜態文件

事實上咱們不能用數聽說明webapp(前端渲染)的體驗就必定比服務器端渲染好,因此Hybrid內嵌就變成了主要的因素,現有的Hybrid有兩種方案:

① webview直連線上站點,響應速度慢,沒有升級負擔,離線應用不易;

② 將靜態html+js+css打包進native中,直接走file模式訪問,交互走json,很是簡單就能夠實現離線應用(某些頁面的離線應用)

如今一個產品通常三套應用:PC、H5站點、APP,PC站點早就造成,H5站點通常與APP同步開發,Hybrid中的邏輯與H5的邏輯大同小異,因此

H5站點與Hybrid中的靜態文件使用一套代碼,這個是使用前端渲染的主要緣由,意思是H5程序結束,APP就完成80%了。

由於服務器端渲染須要使用動態語言,而webview只能解析html等靜態文件,因此使用前端渲染就變成了必須,而這一套說辭基本能夠說服多數人,自少我是信了。

攔路虎-SEO

上面說了不少前端渲染的問題,什麼手機性能、手機耗電、ABTesting都不是痛點,惟一難受的是H5站點的SEO,以原來公司酒店訂單來講,有20%以上的流量來源於H5站點,瀏覽器是一個流量的重要來源,SEO不可丟棄。

因此前端渲染必須有解決SEO的方案,而且方法不能太爛,不然框架出來了也沒人願意用,好在此次作的項目不是webapp,SEO方案相對要簡單一點,移動端展現的信息少SEO不會太難,這個進一步下降了咱們的實現難度,通過幾輪摸索,我這兩天想了一個簡單的方案,正在驗證可行性。

JS渲染應該如何作

前端渲染應該如何作?阿里的大神們事實上一直也在思考方案,而且彷佛已經有成功的產出:先後端分離的思考與實踐(二)

惋惜,讀過文章後,依舊沒有得到對本身有用的信息,而且對應的代碼也看不到,本身以前的方案:探討webapp的SEO難題(上),連本身都以爲很是戳而沒有繼續。

編譯的過程

而最近在公司內部使用fis時候,一段代碼引發了個人興趣:

{%block name="body"%}
    {%widget name="webapp:widget/index/route/route.tpl"%}
    {%widget name="webapp:widget/index/searchCity/searchCity.tpl"%}
    {%widget name="webapp:widget/index/selectDate/selectDate.tpl"%}
{%/block%}

這段代碼基於smarty模板,運行會通過一次release過程,將真正的route模板字符串與服務器data造成最終的html,這段代碼引發了個人思考,卻說不出來什麼問題。

我偶然又看到了以前的react解決方案,彷佛也有一個編譯的過程:

React.render( 
  // 這是什麼不是字符串,不是數字,又不是變量的參數……WTF 
  <h1>Hello, world!</h1>, 
  document.getElementById('example') 
); 
//JSX編譯轉換爲javascript==>
React.render( 
  React.DOM.h1(null, 'Hello, world!'), 
  document.getElementyById('example') 
); 

因此,在程序真實運行前有一個編譯的過程,一個是編譯才能運行,一個是運行時候須要編譯,因而我在想前端渲染能夠這樣作嗎?

頁面渲染的條件

比較簡單的狀況下,對於前端來講,頁面html的組成須要數據與模板,而服務器也僅僅須要數據與模板,因此簡單來講:

html = data + template

先後端的模板有所不一樣的是:

前端模板也許不能被服務器解析,若是模板中存在js函數,服務器模板將沒法執行

可是通過咱們以前的研究,.net能夠運行一個V8的環境幫助解析模板,java等也有相關的類庫,因此此問題不予關注,第二個問題是:

前端數據爲異步加載,服務器端爲同步加載,可是:

簡單狀況下,服務器端與前端數據請求須要的僅僅是URL與參數

因而,一個方案彷佛變的可能。

前端渲染方案

入口頁

將如咱們的index.html是這樣的:

debug端:

<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8">
    <title></title>
    <script type="text/javascript" src="./libs/zepto.js"></script>
    <script type="text/javascript" src="./libs/underscore.js"></script>
    <script type="text/javascript" src="./libs/require.js"></script>
</head>
<body>
<%widget({
name: 'type',
model: 'type',
controller: 'type'
}); %>
</body>
</html>

其中name對應的爲模板文件,而model對應的是數據請求所需文件,controller對應控制器,咱們這裏使用grunt造成兩套前端代碼,分別對應服務器端前端:

注意:這裏服務器實現暫時使用nodeJS,該方案設想是能夠根據grunt打包支持.net/java/php等語言,可是樓主服務器戰五渣,因此你懂的

服務器端:

<!DOCTYPE html>
<html>
  <head>
    <title>測試</title>
    <script type="text/javascript" src="./libs/zepto.js"></script>
    <script type="text/javascript" src="./libs/underscore.js"></script>
    <script type="text/javascript" src="./libs/require.js"></script>
  </head>
  <body>
    <%-widget({
      name: 'type',
      model: 'type',
      controller: 'type'
    }); %>
  </body>
</html>

前端:

 1 <!DOCTYPE html>
 2 <html>
 3 <head lang="en">
 4     <meta charset="UTF-8">
 5     <title></title>
 6     <script type="text/javascript" src="./libs/zepto.js"></script>
 7     <script type="text/javascript" src="./libs/underscore.js"></script>
 8     <script type="text/javascript" src="./libs/require.js"></script>
 9     <script type="text/javascript">
10         require.config({
11             "paths": {
12                 "text": "./libs/require.text"
13             }
14         });
15 
16         var render = function (template, model, controller, wrapperId) {
17             require([template, model, controller],
18             function (template, model, controller) {
19                 //調用model,生成json數據
20                 model.execute(function (data) {
21                     data = JSON.parse(data);
22                     if (data.errorno != 0) return;
23                     //根據模板和data生成靜態html,並造成dom結構準備插入
24                     var html = $(_.template(template)(data));
25                     var wrapper = $('#' + wrapperId);
26 
27                     //將dom結構插入,而且將多餘的包裹標誌層刪除
28                     html.insertBefore(wrapper);
29                     wrapper.remove();
30                     //執行控制器
31                     controller.init();
32                 });
33             });
34         };
35     </script>
36 </head>
37 <body>
38 <div id="type_widget_wrapper">
39 <script type="text/javascript">
40     render('text!./template/type.html', './model/type', './controller/type', 'type_widget_wrapper');
41 </script>
42 </div>
43 </body>
44 </html>

雖然,我這裏grunt的程序還沒有實現,可是根據以前的經驗,這是必定能實現的。

model的設計

默認入口端model爲一個json對象

debug端&服務器端:

{
    "url": "http://runjs.cn/uploads/rs/279/2h5lvbt5/data.json",
    "param": {}
}

由於服務器端僅僅須要一個url一個param,因此服務器端與debug端保持一致,而前端被grunt加工爲:

define(function () {
    return{
        url: './data/data.json',
        param: {},
        execute: function (success) {
            $.get(this.url, this.param, function (data) {
                success(data);
            })
        }
    };
})

顯然,此數據源文件比較簡單,真實狀況不可能如此,咱們這裏也僅僅作demo說明,後續逐步增強。

服務器端運行流程

服務器端因爲是基於node的,首先須要配置app,這裏將全部路由所有放到index.js中:

 1 var express = require('express');
 2 var path = require('path');
 3 var favicon = require('serve-favicon');
 4 var logger = require('morgan');
 5 var cookieParser = require('cookie-parser');
 6 var bodyParser = require('body-parser');
 7 var http = require('http');
 8 
 9 var routes = require('./routes/index');
10 
11 var app = express();
12 
13 // view engine setup
14 app.set('views', path.join(__dirname, 'views'));
15 app.set('view engine', 'ejs');
16 
17 // uncomment after placing your favicon in /public
18 //app.use(favicon(__dirname + '/public/favicon.ico'));
19 app.use(logger('dev'));
20 app.use(bodyParser.json());
21 app.use(bodyParser.urlencoded({ extended: false }));
22 app.use(cookieParser());
23 app.use(express.static(path.join(__dirname, 'public')));
24 
25 //所有路由放到index中
26 routes(app);
27 
28 // catch 404 and forward to error handler
29 app.use(function(req, res, next) {
30   var err = new Error('Not Found');
31   err.status = 404;
32   next(err);
33 });
34 
35 
36 // development error handler
37 // will print stacktrace
38 if (app.get('env') === 'development') {
39   app.use(function(err, req, res, next) {
40     res.status(err.status || 500);
41     res.render('error', {
42       message: err.message,
43       error: err
44     });
45   });
46 }
47 
48 // production error handler
49 // no stacktraces leaked to user
50 app.use(function(err, req, res, next) {
51   res.status(err.status || 500);
52   res.render('error', {
53     message: err.message,
54     error: {}
55   });
56 });
57 
58 
59 app.set('port', process.env.PORT || 3000);
60 http.createServer(app).listen(app.get('port'), function(){
61   console.log('Express server listening on port ' + app.get('port'));
62 });
63 
64 module.exports = app;
View Code

index的代碼:

 1 var express = require('express');
 2 var path = require('path');
 3 var ejs = require('ejs');
 4 var fs= require('fs');
 5 var srequest = require('request-sync');
 6 
 7 var project_path = path.resolve();
 8 var routerCfg = require(project_path + '/routerCfg.json');
 9 
10 //定義頁面讀取方法,須要同步讀取
11 var widget = function(opts) {
12   var model = require(project_path + '/model/' + opts.model + '.json') ;
13   //var controller =project_path + '/controller/' + opts.controller + '.js';
14   var tmpt = fs.readFileSync(project_path + '/template/' + opts.name + '.html', 'utf-8');
15 
16   //設置代理,直接使用ip不能讀取數據,可是設置代理的化,代理不生效,只能直接讀取線上了......
17   var res = srequest({ uri: model.url, qs: model.param});
18 
19   var html = ejs.render(tmpt, JSON.parse(res.body.toString('utf-8')));
20 
21   //插入控制器,這個路徑可能須要調整
22   html += '<script type="text/javascript">require(["controller/' + opts.controller + '"], function(controller){controller.init();});</script>';
23 
24   return html;
25 };
26 
27 var initRounter = function(opts, app) {
28   //根據路由配置生成路由
29   for(var k in opts) {
30     app.get('/' + k, function (req, res) {
31       res.render(k, { widget: widget});
32     });
33   }
34 };
35 
36 module.exports = function(app) {
37   //加載全部路由配置
38   initRounter(routerCfg, app);
39 };

簡單加載流程:

核心點:對於服務器端來講,widget爲一個javascript方法,會根據參數返回一個字符串(由於須要同步返回因此模板讀取,數據訪問皆爲同步進行)

① 訪問/index路徑

② 根據widget參數獲取model數據(json)

③ 獲取model url,而且根據param發送請求獲取數據(這裏的狀況比較簡單,先不要苛責)

④ 根據參數獲取模板

⑤ 根據esj模板(相似於undersocre模板),解析生成html

⑥ 將控制器代碼一require的方式添加到html,最後返回html

啓動node服務,運行之獲得了最終結果:

運行結果:

查看源代碼,能夠看到有完整的html結構:

<!DOCTYPE html>
<html>
  <head>
    <title>測試</title>
    <script type="text/javascript" src="./libs/zepto.js"></script>
    <script type="text/javascript" src="./libs/underscore.js"></script>
    <script type="text/javascript" src="./libs/require.js"></script>
  </head>
  <body>

    <ul id="type_id">
    
    <li class="type js_type">
        <h2>電腦</h2>
        <ul class="product_list">
            
                <li class="product">
                    戴爾
                </li>
            
                <li class="product">
                    蘋果
                </li>
            
                <li class="product">
                    聯想
                </li>
            
                <li class="product">
                    華碩
                </li>
            
        </ul>
    </li>
    
    <li class="type js_type">
        <h2>書籍</h2>
        <ul class="product_list">
            
                <li class="product">
                    三國演義
                </li>
            
                <li class="product">
                    西遊記
                </li>
            
                <li class="product">
                    紅樓夢
                </li>
            
                <li class="product">
                    水滸傳
                </li>
            
        </ul>
    </li>
    
    <li class="type js_type">
        <h2>遊戲</h2>
        <ul class="product_list">
            
                <li class="product">
                    仙劍1
                </li>
            
                <li class="product">
                    仙劍2
                </li>
            
                <li class="product">
                    仙劍3
                </li>
            
                <li class="product">
                    仙劍4
                </li>
            
        </ul>
    </li>
    
</ul><script type="text/javascript">require(["controller/type"], function(controller){controller.init();});</script>

  </body>
</html>
View Code

客戶端流程

客戶端因爲須要異步性,因此生成的結構是這樣的:

1 <div id="type_widget_wrapper">
2 <script type="text/javascript">
3     render('text!./template/type.html', './model/type', './controller/type', 'type_widget_wrapper');
4 </script>
5 </div>

核心代碼爲:

 1 var render = function (template, model, controller, wrapperId) {
 2     require([template, model, controller],
 3     function (template, model, controller) {
 4         //調用model,生成json數據
 5         model.execute(function (data) {
 6             data = JSON.parse(data);
 7             if (data.errorno != 0) return;
 8             //根據模板和data生成靜態html,並造成dom結構準備插入
 9             var html = $(_.template(template)(data));
10             var wrapper = $('#' + wrapperId);
11 
12             //將dom結構插入,而且將多餘的包裹標誌層刪除
13             html.insertBefore(wrapper);
14             wrapper.remove();
15             //執行控制器
16             controller.init();
17         });
18     });
19 };

① 頁面加載,開始解析頁面中的render方法

② render方法根據參數獲取model模塊與template模塊

③ 執行model.execute異步請求數據,並與template造成html

④ 將html造成jquery對象,插入包裝節點前,而後刪除節點

運行結果:

查看源代碼,能夠看到,這些代碼與seo毫無關係:

 1 <!DOCTYPE html>
 2 <html>
 3 <head lang="en">
 4     <meta charset="UTF-8">
 5     <title></title>
 6     <script type="text/javascript" src="./libs/zepto.js"></script>
 7     <script type="text/javascript" src="./libs/underscore.js"></script>
 8     <script type="text/javascript" src="./libs/require.js"></script>
 9     <script type="text/javascript">
10         require.config({
11             "paths": {
12                 "text": "./libs/require.text"
13             }
14         });
15 
16         var render = function (template, model, controller, wrapperId) {
17             require([template, model, controller],
18             function (template, model, controller) {
19                 //調用model,生成json數據
20                 model.execute(function (data) {
21                     data = JSON.parse(data);
22                     if (data.errorno != 0) return;
23                     //根據模板和data生成靜態html,並造成dom結構準備插入
24                     var html = $(_.template(template)(data));
25                     var wrapper = $('#' + wrapperId);
26 
27                     //將dom結構插入,而且將多餘的包裹標誌層刪除
28                     html.insertBefore(wrapper);
29                     wrapper.remove();
30                     //執行控制器
31                     controller.init();
32                 });
33             });
34         };
35     </script>
36 </head>
37 <body>
38 <div id="type_widget_wrapper">
39 <script type="text/javascript">
40     render('text!./template/type.html', './model/type', './controller/type', 'type_widget_wrapper');
41 </script>
42 </div>
43 
44 
45 
46 </body>
47 </html>
View Code

總體目錄

PS:目錄有必定缺乏,由於程序還沒有徹底完成,而最近工做忙起來了......

問題&後續

由於這個方案是本身想的,確定認爲是有必定可行性的,可是有幾個問題必須得解決。

debug煩

如所示,開始階段咱們通常都只開發debug層,可是要調試卻每次須要grunt工具release一下才能運行client中的程序,顯然很差,須要解決。

模板嵌套

模板嵌套問題事實上是最難的,想象一下,咱們在一個模板中又有一個widget,在子模板中又有一個widget,這個就變成了一個噩夢,這裏的嵌套最怕的是,父模塊與子模塊中有數據依賴,或者子模塊爲一個循環,循環卻依賴父模塊單個值,這個很是難解決。

後續

這個想法最近纔出現,剛剛實現一定會有這樣那樣的問題,並且本身的知識體系也達不到架構水平,若是您發現文中任何問題,或者有更好的方案,請您留言,後續這塊的研究暫時規劃爲:

① 完善grunt程序,造成.net方案

② 解決debug時候須要編譯問題

③ 解決模板嵌套、模塊數據依賴問題

④ ......

github

https://github.com/yexiaochai/sword

微博求粉

相關文章
相關標籤/搜索