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, 而且一步步探索, 并寻找更优方案的过程. 虽然有些地方写起来看着挺简单, 好像一笔带过的样子, 可是其中的思考确实不太容易.

相关文章
相关标签/搜索