前言
之前看了一篇文章:@Charlie.Zheng Web系統開發構架再思考-前後端的完全分離,文中論述了為何要前後分離,站在前端的角度來看,是很有必要的;但是如何說服團隊使用前端渲染方案卻是一個現實問題,因為如果我是一個伺服器端,我便會覺得不是很有必要,為什麼要前後分離,前後分離後遺留了什麼問題,如何解決,都得說清楚,這樣才能說服團隊使用前端渲染的方案,而最近我剛好遇到了框架選型的抉擇。
來到新公司開始新專案了,需要做前端框架選型,因為之前內部同事採用的fis框架,而這邊又是使用的php,這次也就直接採用fis基於php的解決方案:
說句實話,fis這套框架做的不錯,但是如果使用php方案的話,我就需要蛋疼的在其中寫smarty模板,然後完全按照規範走,雖然fis規範比較合理,也可以接受,但是稍微深入解後發現fis基於php的方案可以概括為(我們的框架用成這樣,不特指fis):
伺服器端渲染html全部圖給瀏覽器,再載入前端js處理邏輯
顯然,這個不是我要的,夢想中的工作方式是做到靜態html化,靜態html裝載js,使用json進行業務資料通訊,這就是一些朋友所謂的前端渲染了
JS渲染的鄙利
前端渲染會帶來很多好處:
① 完全釋放前端,執行不需要伺服器;
② 伺服器端只提供介面資料服務,業務邏輯全部在前端,前後分離;
③ 一些地方效能有所提升,比如伺服器不需要解析index.html,直接返回即可;
④ ......
事實上以上的說法和優勢皆沒有十足的說服力,根據上述因素,我們知道了為什麼我們要採用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;
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>
客戶端流程
客戶端由於需要非同步性,所以生成的結構是這樣的:
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>
整體目錄
PS:目錄有一定缺少,因為程式尚未完全完成,而最近工作忙起來了......
問題&後續
因為這個方案是自己想的,肯定認為是有一定可行性的,但是有幾個問題必須得解決。
debug煩
如所示,開始階段我們一般都只開發debug層,但是要除錯卻每次需要grunt工具release一下才能執行client中的程式,顯然不好,需要解決。
模板巢狀
模板巢狀問題事實上是最難的,想象一下,我們在一個模板中又有一個widget,在子模板中又有一個widget,這個就變成了一個噩夢,這裡的巢狀最怕的是,父模組與子模組中有資料依賴,或者子模組為一個迴圈,迴圈卻依賴父模組單個值,這個非常難解決。
後續
這個想法最近才出現,剛剛實現必定會有這樣那樣的問題,而且自己的知識體系也達不到架構水平,如果您發現文中任何問題,或者有更好的方案,請您留言,後續這塊的研究暫時規劃為:
① 完善grunt程式,形成.net方案
② 解決debug時候需要編譯問題
③ 解決模板巢狀、模組資料依賴問題
④ ......
github
https://github.com/yexiaochai/sword