一个web项目的总结

来公司快一年了,除了日常APP内webview活动页面开发之外,最主要的工作就是重构了公司的web项目,包括PC站、Wap端、微信公众号和小程序。

除了前端开发工作之外,还负责后端路由和视图对接,以及一些业务功能的实现。现在回过头来整理一下整个项目,包括技术选型、开发环境搭建、项目分组等细节。

<!--more-->

1. 需求

重构有一个最重要的需求,即SEO优化

旧版本的PC站采用的是angularJS,Wap站采用的是weex打包构建的web端项目,均是通过JavaScript动态生成页面内容,进行SEO优化工作比较困难。

由于公司的后台采用的是PHP,如果切换到Node然后使用SSR,维护成本比较高,因此决定采用原始的服务端渲染,后台框架使用Laravel。

2. 数据接口

服务端渲染带来的一个问题是:由于需要返回视图,早期的部分纯数据接口不能直接使用了。

此外还需要添加一些来自于APP的新功能,如果重新写数据模型,需要花费大量时间精力,且后期需要维护两个地方(APP数据接口服务器和新的Web服务器)。

为了保持三端数据、逻辑同步,重写模型和业务逻辑的成本实在是太高了(加上我本来是写前端的~)。

这里的初期设计是:将web当做是跟iOS、Android客户端一样的一个新平台,统一请求后台接口服务器。

2.1. PHP进行客户端请求

大体流程是通过请求接口服务器返回的数据,渲染对应模板,然后返回给浏览器。采用的网络请求库是Guzzle

由于接口很多,因此按照业务模块对接口进行分组,并命名为XXXApiModel此类形式,用于表示为网络接口模型。

2.2. 网络请求效率

在开发过程中遇见的一个问题是网络请求效率。由于每个页面需要展示多个数据模块,这意味着可能需要进行多次接口调用。

然而Guzzle的默认网络请求是串行的*,这意味着页面打开时间是大于等于所有接口请求消耗时间总和的,这是一件完全不能容忍的事情。

庆幸的是通过Guzzle提供了异步请求和Promise来实现并发请求。最后在项目中对Guzzle进行封装,实现了将某个控制器方法中的所有请求都进行并发处理的功能:

  • 请求由不同的模型生成,最后统一发送,这里可以使用单例实现。
  • 需要保证模型在控制器中的调用习惯(即同步代码),这是因为需要根据返回的数据处理部分业务逻辑,可以使用闭包和数据引用传递实现

最终实现的效果类似于

// 请求api数据接口
TestModel::getInstance()->test_1000_c($data['data1']);
TestModel::getInstance()->test_2000_c($data['data2']);

// 发送并发请求
TestModel::getInstance()->startAsync();

这在之前整理过一篇博文:使用Guzzle并发请求接口。如果在Node中,直接使用Promise.all就可以了哈哈。

2.3. 中转请求信息

部分接口,除了依赖于参数传递之外,还需要获取原始的网络请求信息,如Cookie、自定义Header等。

在项目中,通过Laravel的Request对象获取浏览器的请求信息,然后对Guzzle进行封装,在请求接口时带上对应的数据即可。

3. 项目分组

前面提到的数据接口,肯定是需要多个web平台(PC、Wap、小程序)共用的,在这一个大的web项目下,为了方便开发和后续维护,需要对这些平台进行分组。

3.1. 路由分组

在项目中,通过Laravel的路由分组,结合PHP的命名空间,对不同端的web项目路进行分组,包括

  • PC站
  • Wap端
  • 小程序

比如下面是PC站的路由

// pc.php
$pcRoute = function () {
    // ... 定义路由
}
Route::group([
    "namespace" => "Home",
    "domain"    => "www.xxx.la"
], $pcRoute);

下面是Wap端的路由

// mobile.php
$mobileRoute = function () {
    // ... 定义路由
}
Route::group([
    "namespace" => "Mobile",
    "domain"    => "m.xxx.la"
], $mobileRoute);

RouteServiceProvider中,引入对应的路由文件接口

protected function mapApiRoutes()
{
    Route::middleware(['pc'])
        ->namespace($this->namespace)
        ->group(base_path('routes/api.php'));
}

protected function mapMobileRoutes()
{
    Route::middleware('mobile')
        ->namespace($this->namespace)
        ->group(base_path('routes/mobile.php'));
}

另外对应的控制器命名空间,也按照不同的平台进行分组

- Pc
- Mobile
- Applet
- Common

3.2. 公共代码

在对项目进行分组后发现,多个项目之间有不少公共的代码,

  • 在后端第三方oAuth登录、获取验证码、购买接口等业务逻辑
  • 前端比如公共的样式、工具库函数等

针对前后端的公共代码,处理方式也不尽相同

  • 后台的公共代码,通过PHP的继承,提取父类控制器,然后在不同平台的子类控制器进行重载
  • 前端的公共代码
    • 样式表通过SCSS进行管理,拆分成多个样式单元,并在不同平台按需引入对应的公共样式模块(比如混合器、颜色变量等)
    • 公共脚本遵循JavaScript模块化,最后通过webpack打包。相关流程在下面的开发环境搭建再提

至于接口相关的代码,原本设计就是公共的ApiModel,因此肯定是最大程度上的共用。

这里遇见的一个问题是:由于项目的渐进式开发的,先开发PC站,然后是Wap站,接着处理微信公众号,最后是微信小程序。因此公共模块的提取是边开发边进行的,这导致前期有一些不太合理的代码设计。

比如Wap端和小程序的部分控制器功能基本相同,只是Wap端返回视图,而小程序返回JSON数据而已。这是接下来需要进行优化的地方,大概思路就是现在博客V0.4.0的样子,通过判断请求来源,在响应中间件中决定返回内容。

不过由于各个平台的业务侧重不同,因此将Wap端和小程序的控制器放在一起并不是一个明智之选,分组还是很有必要的。

3.3. 中间件

之前写过一段时间的Node,因此对于中间件这个概念并不陌生,Laravel中的中间件,尽管实现略有差异,但基本思想是相同的,即将业务逻辑进行分层。

在项目中有不少使用中间件的地方,比如各个平台都有各自的名称中间件(如pcmobileapplet),还有一些公共的中间件,比如判断登录状态进入个人中心的personal中间件。

印象比较深刻的是web数据统计,由于业务需要统计用户的渠道来源,实现的原理是在分享链接后到特定渠道的id值,然后保存到session中,并关联对应的用户。

在后续的迭代中,对这个功能做了很多扩展,之前将该功能封装在中间件中,在迭代的时候不用去修改对应的控制器方法,只需要专注特定的中间件即可,这对于代码维护还是很方便的。

4. 前端开发环境

整个项目,最开始决定使用的打包工具是webpack,但是在开发流程中遇见了下面两个问题

4.1. 直接开发blade模板

由于我还需要负责后端路由、控制器和视图,因此直接开发Laravel的blade视图模板,跳过了开发静态页面的步骤,

  • 单个站点的页面不多,但公共样式较多,因此所有页面共享一个样式表
  • 每个页面独立脚本文件

公司的开发环境是windows,由于直接开发模板,因此使用WAMP搭建的PHP本地开发环境,这意味着没必要在因此webpack-dev-server搭建开发服务器,此外如HtmlWebpackPluginExtractTextPlugin等插件也就不太适合了。

事实上我之前也折腾过通过HtmlWebpackPlugin输出blade模板(详细记录在这里),然后后来发现这个完全是瞎折腾,还是直接写PHP模板比较合适。

因此最后的选择是:基于gulp进行环境搭建,使用gulp-webpack进行脚本打包,使用gulp-sass进行样式表打包

由于存在多个平台,因此使用gulp-config.js配置文件,设置设置env参数选择对应的开发模块(参数可设为pcmobile),决定最终的输出目录。

然后在对应的gulpfile.js文件中读取gulp-config.js配置文件,编写相关的任务即可,代码热更新使用的是gulp-watch插件。

// gulp-config.js
module.exports = {
    env: "mobile", // 用于切换工作环境
    pc: {
        folder: "assets",
        webpack: function (PAGE_SCRIPT_PATH) {
            return {
                entry: {
                    // common : PAGE_SCRIPT_PATH + "/common.js",
                       // ... 其他页面
                },
                output: {
                    filename: "page/[name].js"
                },
                // 其他webpack配置...
            }
        }
    },
    mobile: {
        folder: "mobile",
        webpack: function (PAGE_SCRIPT_PATH) {
            // 移动端webpack配置
        }
    }
};

小程序的前端开发使用了wepy框架,因此直接使用wepy集成好的开发环境(只是将默认less修改为了scss),因此位于另外一个独立分支上面。

4.2. 位于CDN上面的依赖库

在开发中,大部分JavaScript依赖库都保存在CDN上面,如何统一管理这些CDN依赖也是一个比较重要的问题。如果在每个页面上都手动引入库文件,然后配置webpack的externals,最后在文件中引入依赖,则后期很难进行维护。

关于webpack与CDN的问题,之前也整理过一篇文章。大体思路是通过模块加载器加载cdn依赖库,然后再通过webpack进行打包。

在项目中使用的是SystemJS加载cdn模块

// base.js
let SystemJS = require("SystemJS");
SystemJS.config({
    map: {
        jquery: cdn('jquery/jquery-3.2.1.min.js'),
        layer: cdn('layer/layer.js'), // layer弹窗
        swiper: cdn('swiper/swiper.min.js'), // 轮播图swiper
        swiper: cdn('vue/vue.min.js'), // vue
        // 其他
    },
    depCache: {
        layer: ['jquery'],
    }
});

base.js是所有页面公共的依赖脚本,每个页面还具有自己的独立页面脚本,负责当前页面的业务逻辑,手动引入也比较麻烦,因此写了一个加载器,根据localtion.href判断当前页面,然后自动引入对应脚本

// base.js
let pageMap = {
    // 这里配置当前页面对应的脚本路径,实际上是gulp-webpack输出的脚本文件
    // 这里的实现不太合理,后续可能会重构
}

let page = (function (pageMap) {
    let path = location.pathname,
        page = pageMap[path];
    // ... 路由正则检测
    return page;
})(pageMap);

page && Promise.all([
    SystemJS.import(page),
]).then(res=>{
    // 公共代码
});

然后,只需要在具体的页面文件中,通过SystemJS.import手动引入依赖模块

// home.js
Promise.all([
    SystemJS.import("jquery"),
    SystemJS.import("swiper"),
    SystemJS.import("layer"),
]).then(([$, Swiper, layer]) => {
    // 业务代码
})

实际上SystemJS的使用与RequireJS比较相似,但是模块的定义不用局限于AMD形式,这样子的迁移成本比较低,也方便后续维护。

4.3. 模板管理

回到页面开发,blade是一个非常棒的模板引擎(类似于JavaScript中的swig),它提供了两个非常方便的功能

  • 父模板继承,可以规划整站的基础布局,包括header、footer等,此外还可以统一加载样式表、公共脚本等。
  • 子模板引入,可以用来提取公共的布局组件

通过这两个功能,开发模板变得十分轻松。

将子模板拆分成组件的另外一个好处是,在后续迁移到小程序的过程中,基本上不需要修改页面结构和样式,只需要替换标签为小程序的viewtext即可。

由于PC站和Wap站的视图都位于/views文件夹下,因此我们也需要对视图文件进行分组,这样方便修改和定位问题。

5. 优化

5.1. SEO优化

由于早期旧网站对于SEO并不友好,导致百度快照更新周期十分长(大概二十多天一次),SEO的需求十分重要,由于我对于SEO并不是特别了解,因此在开发时也处于边摸索边尝试的阶段,主要从下面几个方面入手

TDK

TDK是SEO的一个缩写术语,即titledescribekeywords。这也是SEO最基本的三个元素。

由于公司的业务主要是小说阅读,整个站点的页面可分为内容聚合页和内容详情页两大部分

  • 内容聚合页包括搜索、排行榜等页面,这些页面适合堆砌小说分类关键字,提高整站的排名
  • 内容详情页包括小说首页、章节阅读页,这些页面适合填充小说名称等关键字,提高单本小说的搜索几率

而具体的修改操作,则参考了起点、红袖添香等著名的小说阅读网站。

比如下面是小说阅读页的标题形式

小说标题_小说分类_作者名_站点名

针对不同的页面,可以动态生成最适合的描述和关键字,这样有助于整体的优化效果。

URL优化

当时采用Laravel的一个重要原因就是它支持自定义路由,除了前面提到的路由分组之外,自定义路由可以更方便地进行SEO优化。

较短的URL更适合SEO,此外在内容详情页,将路径中的/转换成-符号,进一步减少URL层次。

代码优化

在开发模板时注意页面结构层次,配置链接title、图片alt等属性。

历史链接

由于历史问题,旧网站的域名跟现在采用的域名并不太一样,导致百度搜索显示的域名还是旧的。

这里的处理方式是通过服务器设置304重定向,然后去站长平台提交死链,大概半个月后,收录了新的站点域名。

5.2. 性能优化

性能优化方面,主要包括图片懒加载、精灵图、资源CDN、配置缓存等常规的web优化方案...

6. 小结

虽然在项目中写了很多PHP,但对于数据库接触到的仍旧比较少,主要负责路由和控制器视图这块,因此也算是前端的工作。

就前端而言,主要实现了将前端开发环境嵌入视图开发流程中,以及处理模块加载、性能优化等工作。

就后台而言,完成整个项目,对于传统服务端渲染web项目有了更加丰富的经验,也认识到了MVC框架中各个层的作用和分离。Laravel是比较先进的后台开发框架,其中还有很多需要学习的东西,包括内部的原理和一些设计模式。

现在回过头看,整个项目还有很多不足的地方,早期项目分组和命名空间等也存在不合理的地方,踩过的坑都是经验哈哈哈,继续努力。