Angular Prerender SEO實踐

前導0

angular.js好用, 可是有一點很差的就是, 對於SEO不友好, 由於angular更適合於SPA單頁面應用. 這樣的話, 全部的html都是使用angular動態生成的. 所以搜索引擎就沒有辦法對整個網站進行索引.javascript

對於這個問題, 我看了一篇文章javascript SEO. 看了這篇文章後, 對於使用angular的SEO, 有了一個簡單的瞭解. 而且看到了線上已經在運行的一個網站http://answers.gethuman.com/, 知道按照文章中說的是徹底能夠既對搜索引擎友好, 同時又能徹底發揮angular的優點, 來構建一個單頁面應用的.html

通過和博客做者的郵件溝通, 瞭解了一些具體的細節, 同時我也想經過一個例子進行試驗一下. 因此本身進行了一番嘗試, 在嘗試的過程當中, 天然遇到了一些問題. 通過一步步的尋找並解決, 如今對於angular單頁面應用的SEO問題有了一個大致的瞭解, 所以在這裏記錄一下.前端

過程1 - 實現後端Prerender

實現這個思路應該不是太難, 個人作法是, 在後端使用ejs進行渲染, 在前端就是angular自己的渲染了. 這樣雖然會存在兩套模板, 可是其實成本並不大, 通過後面的說明就能明白.java

對於數據來源, 個人作法是, 在後端有一個數據獲取層, 一個API層. 在前端就是angular的獲取數據層.json

  1. 後端的數據獲取層, 只負責獲取數據的邏輯部分, 輸出的是結構化的數據.
  2. 後端的API層, 對上面的數據獲取層, 進行json或者jsonp的包裝, 返回給前端.
  3. 前端angular的數據獲取, 經過2中的API層進行數據獲取.

渲染流程爲:後端

  1. 後端ejs部分, 直接經過後端的數據獲取層, 拿到數據進行渲染.
  2. 前端的angular部分, 則經過後端的API層獲取數據, 進行前端渲染.

因爲後端的API層, 只是對數據進行簡單的json或jsonp封裝, 所以, 先後端拿到的數據其實是同樣的. 這樣就能保證, 先後端兩套模板的邏輯是同樣的, 只是ejs和angular模板語法的一些簡單差別, 好比循環, if判斷等等. 只須要拿其中一套模板, 而後將語法變成另一種便可, 因此對於維護的成本, 我的感受並非太大.api

過程2 - 前端angular的渲染問題

前端若是要使用angular進行數據綁定, 用戶交互等操做, 就須要讓angular接管頁面的所有或部分. 因爲這裏我是徹底使用angular + angular-uirouter, 所以這裏就是接管所有頁面了.promise

可是這裏有一個問題.服務器

若是將後端渲染的內容填充在ui-view中, angular渲染頁面時須要的數據是在頁面加載完成後, 經過接口獲取的, 這個過程有等待, 可是angular在渲染以前就會把ui-view之間的內容所有清理掉, 就會形成剛進入頁面是正常的, 而後頁面忽然空白一段時間(此時正在進行數據獲取), 而後再次加載的問題.ide

若是將後端渲染的內容單獨放到頁面的一個部分中, 這部份內容是不受angular控制的. 同時, angular也會渲染一份相同的模板, 形成模板重複的問題.

因此爲了解決這個問題, 我進行了一個小hack.

我把整個頁面的結構寫成這樣

<body ng-controller="topCtrl">
    <div ui-view ng-hide="initLoad"></div>

    <div ng-if="initLoad"><!-- 這裏是後端模板渲染的部分. -->
    </div>
</body>

js部分寫成這樣

angular.module('demo', ['ngResource', 'ui.router'])
.config(['$stateProvider', function($stateProvider){
    $stateProvider.state('state1', {
        url: '/state1/:param1',
        templateUrl: 'tpl/template.html',
        controller: 'demoCtrl'
    })
}])
factory('Resource1', ['$resource', function($resource){
    return $resource('/api/:param1', {
        param1: '@param1'
    });
}])
.controller('topCtrl', ['$scope', '$rootScope', function($scope, $rootScope){
    // initLoad肯定第一次加載頁面時, angular不會把後端加載的頁面清掉.
    // 當頁面加載後, 設置initLoad爲false, 當下一次進行angualr操做時,
    // 就能夠自動將後端渲染的東西清理掉.
    var initLoad = $scope.initLoad = true;
    $scope.markInit = function(){
        // 若是是首次加載, 此處只是將標記更新一下, 而後直接返回,
        // 當下次再執行此方法時, 就須要使用angular渲染ui-view來替換後端渲染的模板
        if(initLoad){
            initLoad = false;
            return;
        }
        // 當$scope.initLoad的值變爲false後, angular就會自動把後端渲染的模板清理掉.
        // 而後展現使用ui-view渲染的前端模板
        $scope.initLoad = false;
    };

    $rootScope.$on('$stateChangeStart', function(){
        $scope.markInit();
    });
}])
.controller('demoCtrl', ['$scope', 'Resource1', '$stateParams', function($scope, Resource1, $stateParams){
    Resource1.query({
        param1: $stateParams.param1
    }).$promise.then(function(data){
        $scope.data = data;
    })
    // ...
}])

實現思路是, 讓ui-view部分先隱藏起來, 只顯示後端渲染部分. 當前端進行了一些操做, 須要跳轉到ui-view的其它狀態時, 再把服務端渲染的html去掉.

重點部分是topCtrl中的initLoad這個東西. 咱們先把這個變量設爲true或false,來保證ui-view部分是隱藏或顯示.

在angular和uirouter初始化頁面的時候, $rootScope會觸發$stateChangeStart這個事件, 咱們就利用這個事件來知道, 當前展現的頁面是不是從服務端渲染來的, 仍是後來由angular渲染來的.

第一次觸發這個的時候, 是angular在進行首次渲染, 不該該把$scope.initLoad設爲true, 因此咱們只是把initLoad這個臨時變量設爲false, $scope.initLoad仍然爲true.

當下一次再觸發的時候, 首先檢查initLoad這個變量, 此時爲false, 證實不是首次加載了, 因此須要將$scope.initLoad設爲false. 一旦$scope.initLoad變成false後, ng-if就會起做用, 將後端渲染的模板清理掉, 同時, 將angular渲染的模板展現出來.

這樣, 過程2開頭說到的問題基本就解決了.

過程3 - 保證首次加載後, 用戶交互仍然可用.

過程2中只是作到後端渲染模板與前端渲染模板不衝突, 可是還沒法解決一個問題. 如何保證在首次加載的後端模板不清理的狀況下, 正確響應用戶的click dblclick這些操做呢? 這些部分但是不在ui-view的controller控制之下的.

解決辦法, 利用$scope的繼承特性.

整個代碼修改成下面這樣.

angular.module('demo', ['ngResource', 'ui.router'])
.config(['$stateProvider', function($stateProvider){
    $stateProvider.state('state1', {
        url: '/state1',
        templateUrl: 'tpl/template.html',
        controller: 'demoCtrl'
    })
}])
factory('Resource1', ['$resource', function($resource){
    return $resource('/api/:param1', {
        param1: '@param1'
    });
}])
.controller('topCtrl', ['$scope', '$rootScope', function($scope, $rootScope){
    // initLoad肯定第一次加載頁面時, angular不會把後端加載的頁面清掉.
    // 當頁面加載後, 設置initLoad爲false, 當下一次進行angualr操做時,
    // 就能夠自動將後端渲染的東西清理掉.
    var initLoad = $scope.initLoad = true;
    $scope.markInit = function(){
        // 若是是首次加載, 此處只是將標記更新一下, 而後直接返回,
        // 當下次再執行此方法時, 就須要使用angular渲染ui-view來替換後端渲染的模板
        if(initLoad){
            initLoad = false;
            return;
        }
        // 當$scope.initLoad的值變爲false後, angular就會自動把後端渲染的模板清理掉.
        // 而後展現使用ui-view渲染的前端模板
        $scope.initLoad = false;
    };

    $scope.addMethod = function(evtName, func){
        // 此處的this指向的是ui-view對應的controller中的$scope
        this[evtName] = func;
        $scope[evtName] = func;
    };

    $rootScope.$on('$stateChangeStart', function(){
        $scope.markInit();
    });
}])
.controller('demoCtrl', ['$scope', 'Resource1', '$stateParams', function($scope, Resource1, $stateParams){

    $scope.addMethod('clickImg', function(){
        alert('click img');
    });

    Resource1.query({
        param1: $stateParams.param1
    }).$promise.then(function(data){
        $scope.data = data;
    })
    // ...
}])

這樣, 假如, 後端渲染部分以下

<div ng-if="initLoad"><!-- 這裏是後端模板渲染的部分. -->
    <img src="" alt="" on-click="clickImg()">
</div>

這樣修改以後, ui-view的controller添加一個方法後, 上層的topCtrl就能添加一樣的方法, 就能正確響應用戶的操做了.

只是, 這種修改方法有一個很差的地方. 若是我先寫一個前端模板, 而後轉換成ejs模板的語法, 就須要決定, 哪些angular語法須要轉換, 哪些angular語法須要保留, 以便可以正確響應用戶操做.

固然, 爲了可以達到既使用angular, 又對SEO友好的最終目的, 這一切都不是問題.

過程4 - ngCloak

基本問題解決了, 那就寫一個頁面吧. 此時的頁面能夠後端prerender, 首次進入頁面後, 也沒有頁面閃動現象, 還可以正確響應用戶的一些操做, 看上去一切彷佛都是perfect. 可是, 仍是有不少問題.

頁面閃動, 這裏的頁面閃動, 是後續的操做中的頁面閃動, 從一個ui-view的state轉換到另外一個state的時候, 就像前面說的, angular會把頁面的內容所有清理掉, 而後再進行渲染. 而不是, 等一切渲染就緒以後, 再把頁面上的內容清掉.

使用angular ui-view flicker關鍵詞進行搜索後, 發現了使用ng-cloak進行解決的方法, 可是我試驗以後, 基本沒有效果. 由於, ng-cloak的本質是一個class類, 在渲染的過程當中, 是display:none狀態, 當渲染完畢後,把這個class去掉.

看來, 這個東西, 並不能解決我說的問題, 既, 先清理頁面內容, 而後再進行渲染. 因爲渲染過程, 須要到服務器端獲取數據,因此這個過程當中, 整個頁面就是白的.

過程5 - ui-router的resolve

又通過的一番搜索, 搜索到了ui-router中的一個東西, resolve, 經過文檔能夠看到, 這個東西, 是爲了保證, ui-view對應的controller初始化時, 全部依賴的東西都已經加載完畢.

文檔以下

You can use resolve to provide your controller with content or data that is custom to the state. resolve is an optional map of dependencies which should be injected into the controller.

If any of these dependencies are promises, they will be resolved and converted to a value before the controller is instantiated and the $routeChangeSuccess event is fired.

所以, 我把整個js代碼修改爲這樣

angular.module('demo', ['ngResource', 'ui.router'])
.config(['$stateProvider', function($stateProvider){
    $stateProvider.state('state1', {
        url: '/state1',
        templateUrl: 'tpl/template.html',
        controller: 'demoCtrl',
        resolve: {
            // 在這裏進行resource1Data的獲取工做
            resource1Data: ['Resource1', '$stateParams', function(Resource1, $stateParams){
                return Resource1.query({
                    param1: $stateParams.param1
                }).$promise;
            }]
        }
    })
}])
factory('Resource1', ['$resource', function($resource){
    return $resource('/api/:param1', {
        param1: '@param1'
    });
}])
.controller('topCtrl', ['$scope', '$rootScope', function($scope, $rootScope){
    // initLoad肯定第一次加載頁面時, angular不會把後端加載的頁面清掉.
    // 當頁面加載後, 設置initLoad爲false, 當下一次進行angualr操做時,
    // 就能夠自動將後端渲染的東西清理掉.
    var initLoad = $scope.initLoad = true;
    $scope.markInit = function(){
        // 若是是首次加載, 此處只是將標記更新一下, 而後直接返回,
        // 當下次再執行此方法時, 就須要使用angular渲染ui-view來替換後端渲染的模板
        if(initLoad){
            initLoad = false;
            return;
        }
        // 當$scope.initLoad的值變爲false後, angular就會自動把後端渲染的模板清理掉.
        // 而後展現使用ui-view渲染的前端模板
        $scope.initLoad = false;
    };

    $scope.addMethod = function(evtName, func){
        this[evtName] = func;
        $scope[evtName] = func;
    };

    $rootScope.$on('$stateChangeStart', function(){
        $scope.markInit();
    });
}])
.controller('demoCtrl', ['$scope', 'resource1Data', function($scope, resource1Data){
    // 這是再也不注入Resource1以及$stateParams, 而是直接注入resolve中定義的resource1Data
    $scope.addMethod('clickImg', function(){
        alert('click img');
    });

    $scope.data = resource1Data;

    // ...
}])

通過以上修改, 就能保證, 當頁面切換時, 會先去獲取ui-view對應的controller須要的全部注入項, 等全部的注入項都已是resolve狀態時, 再進行controller的初始化工做. 這樣, 頁面閃動的問題就解決了.

過程6 - 完美方案

經過上面的resolve方案, 既然可以解決後續頁面之間切換時的頁面閃動問題, 那是否能夠解決頁面首次加載時的頁面閃動問題呢? 由於首頁加載的頁面冷卻也是因爲resource去獲取數據形成的.

因此, 試驗一下, html代碼修改成下面這樣

<body>
    <div ui-view>
        <!-- 這裏是後端模板渲染的部分. -->
    </div>
</body>

js代碼修改成以下

angular.module('demo', ['ngResource', 'ui.router'])
.config(['$stateProvider', function($stateProvider){
    $stateProvider.state('state1', {
        url: '/state1',
        templateUrl: 'tpl/template.html',
        controller: 'demoCtrl',
        resolve: {
            // 在這裏進行resource1Data的獲取工做
            resource1Data: ['Resource1', '$stateParams', function(Resource1, $stateParams){
                return Resource1.query({
                    param1: $stateParams.param1
                }).$promise;
            }]
        }
    })
}])
.factory('Resource1', ['$resource', function($resource){
    return $resource('/api/:param1', {
        param1: '@param1'
    });
}])
.controller('demoCtrl', ['$scope', 'resource1Data', function($scope, resource1Data){
    $scope.clickImg = function(){
        alert('click img');
    }
    $scope.data = resource1Data;

    // ...
}])

通過試驗, 首頁加載時的頁面閃動問題也能夠解決. 經過上面的方法, 也不須要topCtrl, 由於頁面加載後, angular也會再次渲染, 可是這裏的渲染過程不會出現頁面閃動, 用戶幾乎察覺不到整個頁面由後端模板向前端模板的過渡過程. 對於後端模板正確響應用戶操做的hack, 一樣也能去除.

以上就是我爲了實現angular prerender SEO進行的一些研究, 以及爲了達到一些目標而進行的hack, 而且一步步探索, 並尋找更優方案的過程. 雖然有些地方寫起來看着挺簡單, 好像一筆帶過的樣子, 可是其中的思考確實不太容易.

相關文章
相關標籤/搜索