如何部署前端代码

很早之前在知乎上看见一个提问:大公司里怎样开发和部署前端代码?,其中张云龙(fis3作者)的回答十分精彩。但当时由于水平有限,接触到的业务和开发环境也比较简陋。现在公司推行微服务,前端静态资源也基于fis3进行开发环境搭建和部署,算是这个回答比较完整的实现。

现在,是时候思考一下静态资源部署的重要性了。

<!--more-->

本篇文章主要用于思考代码的部署,而非完整的前端开发流程:开发->打包->部署。

1. 前端打包思考

服务端代码都部署在公司主机或者云服务器上面,前端代码却需要运行在客户端各种各样的浏览器上面,这种差别决定了前后端部署的差异。

当问到常见的前端性能优化方法,一般的回复即为

  • 为了减少http请求,需要将各个模块压缩合并
  • 为了减少传输时间,需要压缩代码

考虑到浏览器渲染过程,我们还需要考虑代码加载和运行的时机。

1.1. 资源打包合并

浏览器的并发请求数目限制是针对同一域名的。意即,同一时间针对同一域名下的请求有一定数量限制。超过限制数目的请求会被阻塞,因此将各个模块打包合并,用来减少文件数量。

在过去的项目中,我先后使用过gulp、webpack、rollup、fis3等多个打包工具。

打包存在的问题在于:如果修改了某个模块的一行代码,则依赖这个模块的所有文件都需要重新打包,最终部署时也需要更新打包后的文件。这种行为看起来并不是很合理,因为如果我们不进行打包,每个模块都通过script标签引入,则只需要更新那个被修改过的文件即可。

这里有一个比较有趣的问题:

参考

1.2. 静态资源单独使用服务器

一般来说,在部署时,静态资源不会与页面域名保持一致,而是使用一个单独的静态资源服务器域名,该服务器的主要功能就是维护和更新静态资源,不涉及用户状态(cookie、session等),因此请求时不会携带大量的cookie信息,响应速度更快。

此外,独立域名在用来进行feferer验证、防盗链等方面也更加容易。

1.3. 小结

无论是资源打包合并、还是使用独立的静态资源服务器,都避免不了一个问题:如何将生产代码部署到线上。

一般部署都会面临下面几个问题

  • 手动部署十分繁琐且容易出错

  • 缓存机制导致资源更新不及时,一般会更新资源路径(如增加hash后缀、增加查询参数等)来解决

  • 如果资源和页面需要分开部署,则二者部署的先后间隔内,会影响线上用户

下面先回顾一下在我的职业生涯中所经历过的前端代码部署流程。

2. 经历过的前端代码部署流程

回想整个前端工作生涯中,经历过的前端代码部署大约经历了下面几个阶段。

2.1. 直接在模板文件引入静态资源

最初在外包公司工作,接触到传统的MVC框架(ThinkPHP),负责切图及写页面,并为后台提供模板文件和静态资源文件。

在接入模板时,需要将静态资源替换成服务器上对应的的文件路径,如__PUBLIC__全局变量,用于指向服务器上对应可访问的静态资源文件夹。

开发时没有考虑过打包或者合并代码,部署时也没有考虑过资源加载性能和缓存

2.2. webpack打包依赖,合并代码

后面陆陆续续接触到前端工程化的概念,也使用了gulp、webpack等工具搭建前端开发流程。这时候知道了前端项目可以分为单页应用和多页应用,下面是在这两种项目中使用webpack打包的方式

多页应用

多页应用一般采用的都是后台渲染,处理方式是配置多个entry,每个页面输出对应的打包资源

module.exports = {
     entry: {
         background: PAGE_SCRIPT_PATH + '/background.js',
         options: PAGE_SCRIPT_PATH + '/options.js',
         popup: PAGE_SCRIPT_PATH + '/popup.js',
         piseerPage: PAGE_SCRIPT_PATH + '/piseerPage.js',
     },
     output: {
         filename: "[name].js"
     },
}

然后每个模板页面引入各自对应的依赖,由于每次打包可能会涉及到多个页面的修改,

  • 如果在输出文件上增加hash码,则每次改动都需要修改多个文件的依赖,相当麻烦
  • 如果不在输出文件上增加hash码,则文件的缓存控制比较麻烦

这个问题在之前的项目开发中并没有得到很好的解决,采用的办法是:输出文件名不增加hash码,而是在后台模板引擎上增加统一的引入资源方法,添加参数后缀控制。

下面是在Laravel的blade模板中新增php方法loadCDN,实现控制多页面应用的资源引入和版本控制的问题

<script src="{{cdn("/mobile/js/page/base.js")}}" defer></script>
if (!function_exists('cdn')) {
    function cdn($url = '', $isDev = false){
        if ($isDev) {
            // 通过上线时修改版本号来控制
            return url($url . '?_v=1.2.9');
        }
        return '//cdn.xxxHostName.com/' . $url;
    }
}

单页应用

单页应用包含使用vue、react等框架构建的web app,也包含运营需求用到的较为简单的活动页面,这种项目,一般是一股脑将多个文件进行合并,并在单页模板上引入对应的资源。

web app 一般使用相关框架的脚手架进行开发(vue-clicreat-react-app等),这些脚手架都内置了开发和打包流程,最终输出静态资源文件。一般来说,web app业务较多,打包后的文件体积十分庞大,为了优化页面打开速度,一般会采用下面优化手段(参考:Vue SPA 项目webpack打包优化指南

  • 异步路由,页面组件懒加载,减少初次加载文件的体积
  • 库文件打包到vendor.js,配置 externals 使库文件采用cdn加载

简单的活动页面,由于每个活动的逻辑和样式都比较独立,我采用的处理方式是使用html-webpack-inline-source-plugin将输出的代码(CSS和JS)都内联至页面上,直接跳过静态资源部署的问题,同时通过缓存控制html文件体积过大的问题

new HtmlWebpackPlugin({
    template: getPath("./index.html"),
    inlineSource: ".(css|js)quot;,
    minify: {minifyCSS: true, minifyJS: true},
})

3. 独立的静态资源服务器

现在公司的业务较多,后端采用微服务架构,为了在多个项目之间共用前端模块,静态资源单独划分了一个项目,基于fis3实现静态资源服务器。下面整理了大概的流程和实现原理

3.1. 静态资源url

在模板上请求类似资源

http://cnd.xxx.com/statics/combojs?6538b/mod-c6308.js;6538b/zepto.touch.min-1907a.js;bf986/url-a7908.js;bf986/share-0559e.js;6538b/swiper3.08.jquery.min-47b5e.js;

这跟常见的静态资源路径有一些不同,请求的是一个后台路由statics/combojs,携带了一系列包含依赖资源和版本的请求参数。

在后台控制器中,会解析参数,拆分对应的文件资源,并合并对应文件,返回实际的资源文件。

这里存在的一个问题是如何将请求参数的hash码映射到对应的编译文件,这是通过fis3编译生成的statics_url_hash.json进行处理的

{
    // ...
    "ba858": "/statics/h5/poster/js"
}

就会把ba858/index.es6-12c2e.js转换成实际路径/statics/h5/poster/js/index.es6-12c2e.js,然后合并文件。合并后的代码在前端,通过mod.js处理依赖,执行业务逻辑。

合并后的文件一般会做cdn缓存,所以基本上不用考虑磁盘上合并文件的性能消耗。

3.2. 后台服务同步hash

前面提到根据页面上的静态资源url请求对应的combo服务器,那么埋入页面的静态资源url是如何生成的呢?

后台采用的是node进行模板渲染,并在global全局对象上挂载了IncludeAssets方法,用于收集模板中的静态资源依赖,模板上的大致使用方式为

{{css_combo_url}}
<%- IncludeAssets('statics/h5/poster/css/index.scss') %>
<%- IncludeAssets('start:statics/h5/poster/js/index.es6.js') %>
{{js_combo_url}}

页面上可能存在多个IncludeAssets调用,这样就可以在后台按需引入需要依赖的模块,此外并判断环境

  • 测试环境输出scriptlink标签
  • 正式环境输出combo_jscombo_css,携带对应的hashUri

然后通过中间件,在页面完成渲染后通过替换js_combo_urlcss_combo_url占位符,返回合并后的js与css文件,输出对应实际的资源引用标签。

3.3. 更新与部署

在静态资源打包完成时,fis3会输出map.json,表明模块之间的依赖分析。

打包完成后然后将map.json写入redis,通过redis的发布订阅模式,通知各个后台服务拉取最新的依赖映射。

下次请求页面时,就会同步文件hash,同步最新的依赖文件,拼接出对应的url,然后访问静态资源服务器,就会输出最新的静态资源文件了。

3.4. 小结

整个模式大概流程如下

  • 开发时按模块组织代码,通过fis3打包输出的map.json文件,并通过redis将map.json分发给订阅了静态资源服务器的后端项目,
  • 后端项目通过模板中的IncludeAssets方法收集需要的模块文件,并结合最新的map.json依赖映射拼接成script或link标签,
  • 浏览器解析页面时,通过url请求到静态资源服务器,拼接对应的模块文件,返回资源文件。

在这种模式下,考虑下面的改动情景,在部署期间是否会影响线上用户呢?

  • 只改动了页面结构,未改动静态资源,直接上线后台服务即可
  • 只改动了静态资源,直接发布静态资源即可,
    • 由于改动后会更新map.json,生成不同的资源url,返回不同的资源文件,
    • 通过增量更新,不会影响线上旧资源,也不会
  • 同时改动了静态资源和页面结构,
    • 先上线静态资源,同时标记后台服务为待上线状态
    • 在部署静态资源期间,页面结构是旧的,返回的静态资源也是旧的
    • 完成静态资源部署后,再部署后台服务,此时后台服务为正在更新状态,采用的增量更新,返回的静态资源也是旧的
    • 后台服务更新完毕,取消标记,根据新的map.json,生成新的资源url,返回新的静态资源

通过上述操作,避免了在部署期间对于线上用户的影响,也就不需要半夜三更找用户最少的时间段发布代码了。实际上整个流程还有很多优化操作,包括部署回滚、根据url进行资源缓存、redis哨兵等。

4. 总结

最初刚来公司时见到前端开发环境和部署流程,感觉十分复杂,并不明白其中缘由,只能对着文档按规范进行开发。随着接触到的项目变多,逐渐了解到整套开发和部署流程的原理和优势,受益颇多,因此记录下来。

前端并不仅仅只是切图和写页面而已,前端工程化是一个庞大复杂、却又与用户体验相关甚密的问题,需要潜心学习,不断思考才行啊。