终于有机会亲自上手写些前端的东西,之前只是关心表现层的最终效果,但是对于怎么实现的关心不多。这个过程也有助于我了解一下这些年前端发展的能力;也可以重新思考一下前后端切割的边界在哪儿,如何支持好页面,最终能给用户良好的体验。

起因是要做一个设备管理服务,套路基本如下

  • Node写后端服务 API Server,顺便也作为 Web Server。
  • 前端暂未确定,最好听专业人员的。
  • 数据结构简单,因此直接放在Redis里了。
  • 连接设备桌面沿用Gateway/Guacamole,终于把VNC协议用起来了,顺便升个级。
  • 监控沿用InfluxDB/Graphite-Api。
  • 访问入口用Nginx盖一下。
  • 所有服务容器化,base顺便也升个级。

那么,新东西在哪里

  • Node打算多用用ES6之后的新语法,特别是Async/Await,希望能简化代码层次。当时的 Node 7.5 加 harmony 参数就可以用了;半个月之后的 Node 7.6,就成为默认支持了;这都是为后来的 Node 8.0 做铺垫。
  • Web Server倾向于 koa,主要是 koa2 就会正式支持 Async 语法;当时要用可以走 koa@next 分支。Node 7.6 发布后,很快 koa2 也就是默认版本了。
  • 用 Mocha 跑跑 API 自动测试,早期覆盖率100%,后来一路走低。
  • 核心API写完,就开始琢磨前端了,人借不到,就只能自己来了;前端框架沿用iDES的最后尝试,打算用用Vue2;状态管理就自然是Vuex了。
  • 因为涉及打包过程,所以 webpack 也就用起来了;用vue-cli创建示例工程时,webpack 也是首选项。
  • 不想前后端交叉写代码太纠结,所以 babel 转码还是少不了。
  • 不想从头搭页面,就选个风格化的UI库Vue-Material
  • 上面产生的一些组件依赖,再加上调试、打包过程,那么npm、package.json、npm script也就用起来了。

于是,技术栈就成了,Node8.0 + npm + Koa2 + Vue2 + Webpack2 + ES2017;前端所涉及的html、css等知识,纯属补课了。

后端,Node|Koa

  • Node直接看文档,现在已经8.0了;ES6及其之后语法参考 ECMAScript 6 入门
  • koa的中间件叠加机制比较清晰,除了日志等基础层,所有API响应都在一个HTTP路由里了,里面按照Restful定义各个响应模块就行了;各个模块共享一个db实例,用于连接Redis。
  • 前端页面都只是静态资源,所以用koa-static支持。
  • 为了提高前端数据更新效率,后来增加了一个WebSocket通道

前端,Vue2|Vuex|Vuetify

  • Vue的主要工作是界面的组件化,理解一个组件的传入参数props,内部数据data/computed,内部逻辑函数method,生命周期入口mounted/beforeDestory等;然后就是根据对页面承载业务逻辑的理解,不断整理组件的切割和组织方式了,这个会折腾好几拨。参考:Vue.js 2.0 教程Vue.js 2.0 API
  • Vuex管理数据,实现单向数据流的思路,逻辑比较清晰;数据跟页面表现切割开之后,给不断修改界面组件组织方式带来很大便利性,基本不担心破坏业务逻辑。参考:文档
  • Chrome上的Vue.js devtools可以方便的看到Vue组件层次和Vuex的状态数据。
  • UI库最早用的Vue-Material,没什么严重问题,直到实现弹出信息栏时,发现有个提前消失的Bug;寻找替代品时发现了Vuetify,仔细用了一阵觉得封装实现比Vue-Material好用,后来就切换过来了。其实这种转换的代价也不大,UI只是每个组件的表现层,不影响组件自身逻辑;我甚至在几个版本还混用来着,也没什么问题。
  • 其他项目有采用饿了么的element做表现层;因为更倾向于Material的交互,也想保持适应性到手机的弹性,就没采用。
  • 因为Vue,开发工具也顺便换成了VS Code

前端调试,Webpack

  • vue模板里使用webpack-dev-server作为开发调试的服务器,底层调用的是express,支持代码热更新,很是方便。
  • 可以在proxy配置里派发请求到其他可响应服务器上,这样就可以尽量完整的进行页面调试了。
 proxy: {
        '/api/*': 'http://localhost:3000',
        '/live/*': { target: 'ws://localhost:3000', ws: true },
        '/graph/*': 'http://10.99.106.76'
      }

前端打包,Webpack|webpack.config.js

这项目开始只是用了vue-cli的简单模板,后来解决静态资源缓存时,把Webpack的配置折腾了一遍,终于有些上手了。建议先看看从0到1搭建webpack2+vue2自定义模板详细教程,解释的比较清晰。这里稍微展开一下:

  • entry,入口js(比如app.js),用来串联起引用到的css、字体、图片、其他js、vue等类型的文件。
  • output,最终打包输出的文件,最简单可能只有一个build.js。
  • module.rules,就是定义个每个类型文件怎么处理,最主要的可能就是js了,babel转码就在这里。
  • resolve.alias,手动把一些js、css映射到上下文中,这些对象可以在js里直接import的。有趣的是,还可以把一些路径转换为一个虚拟路径,比如'~res': path.join(__dirname, 'res'),使用~res\img\a.png,这样如果路径结构变化,只改打包配置,就不用改js代码了。
  • plugins,额外处理插件,比如压缩代码等处理就在里添加。有趣的是,HtmlWebpackPlugin这个,可以把打包出来的js/css文件路径,动态的插入到html标签里。
  • devServer,就是定义调试服务的配置。
  • 通常,打包过程会区分开发模式和生产模式,习惯上,会先定义一个公用的配置;然后使用 webpack-merge 在往里合并不同的增补项。
  • 开发模式,会有devServer,而且打包输出只保持形如 build.js的形式就好,而且不许要压缩代码等步骤
  • 生产模式,会输出形如 app-8a7sd56f768as5d.jsdeps-a76sd5fas768d5f.js的形式,来保证每次打包出来的文件都不同,避免浏览器缓冲旧版;另外代码压缩,最终输出路径可能也会有所不同。

工程打包,npm|package.json|script

  • 代码的运行依赖的模块都定义在package.json里,App目录和Web前端目录各一个,npm install就可以把依赖包下载到各自目录下的node_modules了。Web下只是用来打包的,App下的才会打包到安装包里。
  • 打包过程使用了npm script的逻辑,当你定义一个tar脚本时,还可以定义pretar/posttar,这两步会在执行 npm run tar时被自动调用:pretar -> tar -> posttar。
  • 沿用bash的方式,用shell+binary的方式完成了一个安装过程。
  • 于是打包的步骤就是,npm run pack >pretar >web\npm run build (webpack打包) >tar 打包所有部署内容 >posttar 制作安装包。
  • 然后就是做Docker镜像了。

有趣的坑,UI库

开始使用的Vue-Material,一直没什么大问题,使用就按照API来写,比较容易上手;还顺便用上了Goolge的Roboto字体和Material icons图标字体,这个以前一直习惯用Font Awesome来着。

所以当UI库有bug时,才稍微调查选择了一下;当然,都是配Vue的UI库:

  • Quasar,比较完整,优先移动端,不过侵入性太强,怕绑定在上面。
  • Vuetify,面向响应式页面,感觉封装方式比Vue-Material更能体现Vue的思路,借此了解了Vue的Slot分发内容,以后会用到。
  • element,更加面向桌面应用。
  • mint,饿了么的移动端UI库。
  • BootstrapVue,习惯Bootstrap的可以考虑。

UI库的选择,目前的考虑是,配合Vue框架、支持响应式页面、桌面和手机上看都比较合适、开源社区的维护能力。当然,最终趋势是公司级的UI组件抽象,也会包括风格的一致化;显然,目前还没有。

有趣的坑,静态资源缓存

因为在关于页面增加了大幅动图作为产品说明,静态资源缓存就成了必须了;补课一下资源缓存如何生效:

  • 一种是靠固定过期时间,HTTP 1.1 头里,主要起作用是 Cache-Control:max-age=3600,Expires和Cache-Control:public现在已经没必要了。
  • 另一种是靠文件变化标识,可以是修改时间 Last-Modified,也可以是 ETag(通常是文件Checksum),ETag更准确些。

获取静态资源的过程如下:

  • 正常第一次请求,返回200,Last-Modified 和 ETag。
Status Code:200 OK
cache-control:public, max-age=0
Connection:keep-alive
Content-Encoding:gzip
content-md5:PAQixkigs0w1fpMsJcWEmw==
Content-Type:text/css; charset=utf-8
Date:Sat, 27 May 2017 10:22:11 GMT
ETag:W/"PAQixkigs0w1fpMsJcWEmw=="
Last-Modified:Sat, 27 May 2017 09:22:41 GMT
Server:nginx/1.10.0 (Ubuntu)
Transfer-Encoding:chunked
Vary:Accept-Encoding
  • 再次请求时,增加判断信息 If-None-Match。
Accept:text/css,*/*;q=0.1
Accept-Encoding:gzip, deflate, sdch
Accept-Language:zh-CN,zh;q=0.8,en;q=0.6
Connection:keep-alive
Host:10.99.106.76
If-Modified-Since:Sat, 27 May 2017 09:22:41 GMT
If-None-Match:W/"PAQixkigs0w1fpMsJcWEmw=="
  • 如果文件没变,服务端就返回403了,这样就不会产生实际的传输了。
Status Code:304 Not Modified
Remote Address:10.99.106.76:80
Referrer Policy:no-referrer-when-downgrade
Response Headers
view source
Connection:keep-alive
Date:Sat, 27 May 2017 10:23:20 GMT
ETag:"PAQixkigs0w1fpMsJcWEmw=="
Last-Modified:Sat, 27 May 2017 09:22:41 GMT

所以如前说明,过了一遍Webpack打包,跟静态缓存有关的是这几项:

  • 在koa-static基础上扩展conditional-getetag,增加ETag信息,是文件MD5值;或者直接使用koa-static-cache;我选择了后者,缓存期一年。
  • 通过Webpack,把动态代码打包为形如app-aa564sdf765a4s.js的文件,打包过程会把动态名字更新到html里;这样每次升级部署,再访问就是新文件了。

坑在哪儿?

有趣的坑,代码压缩

  • js的代码压缩,通常使用UglifyJS,而包括它下个版本UglifyJS 2/3也是不支持ES6的,这要用到 2 的 uglify-es (harmony)分支。
  • Webpack内建的webpack.optimize.UglifyJsPlugin插件,只是调用标准的UglifyJS;如果需要自选版本,需要使用额外的uglifyjs-webpack-plugin,这样就可以使用比如UglifyJS2、uglify-es了。
  • 另一方面,面向转码的babel,显然也考虑了代码压缩这一步,项目是babili (babel-minify);项目说明里还跟 UglifyJS 和其他竞争者做下性能比较的。
  • babili在打包过程用起来有两个方式,一是整合在babel处理过程中babel-preset-babili,转码+压缩一块儿完成,好处是更快一些。
  • 另一种是在Webpack里通过插件babili-webpack-plugin完成,跟调用UglifyJs的位置一样,缺点是稍慢;目前选择这种,比较容易看清楚。

有趣的坑,JS转码

既然在前端也用了ES6等新语法,那么就离不开babel转码这一步,一众presets配置,包括es-2015es-2016es-2017等,可以根据目标环境,把代码转换到可以正常运行的级别;甚至还可以配置子项插件做更精细的转码控制。 但这事儿在浏览器上就坑了,环境太多,要在性能(假设新版浏览器+新语法的综合性能略好)和可用性上做权衡。结合代码压缩的话题,会有这两个方向:

  • 转码到JS低版本,甚至是ES5,这样UglifyJS就可用了,省心;但是没用上新版浏览器的好处,不甘心啊。
  • 设定最低目标浏览器版本,详细控制转码级别;还是麻烦。

这里一直是依赖Chrome 50+的,其实掩盖了不少问题,Chrome 43都跑不下去;使用Babel-Polyfill解决了一部分,但是也不安全。

直到发现了babel-preset-env,这项目的目标是转码到指定运行环境版本,转码级别靠项目共享优化(显然这应该是公用策略);用户只需要定义最终环境就行,支持chrome, opera, edge, firefox, safari, ie, ios, android, node, electron。这下省心了,甚至,还有个uglify参数,来保证压缩正常(暂时用不上了)。于是,babel自己的配置文件也就用起来了:

//.babelrc
{
  "presets": [
    ["env", {
      "targets": {
        "chrome": 46,
        "uglify": false
      },
      "useBuiltIns": true,
      "modules": false
    }]
  ]
}

有趣的坑,WebSocket

基本上,是要给页面增加一个双向数据通道,来保证一些频繁更新的数据可以实时推送到页面上。有一些选择:

  • Socket.IO,是个成熟的实现,有续传等保护机制,也可以借用上标准的WebSocket通道,有多种语言支持;浏览器作为客户端,需要单独的库来支持。
  • WebSocket,这是指W3C标准实现,现代浏览器都支持了。关于 Socket.IO 和 WebSocket 的比较,这个回答可以看看,Socket.IO的控制逻辑,还是增加了不少负担的。
  • Server-Sent Events,支持断点续传的单向推送,其实可以满足大部分需要;但是为了保持浏览器端还可以往回发送一些控制指令(比如消息注册、过滤),就没选这个。

最后还是用了标准的WebSocket,koa-websocket为 Koa 增加了一个 WS 入口,定义单独的响应路由即可。koa-websocket依赖的WS实现是ws,其实node下的WebSocket实现还是有一些选择的,比如µWS。µWS的介绍页面里,还从吞吐量角度跟其他实现做了对比。

有趣的坑,简单页面路由

虽然是单页面应用,但是也希望增加/home、/dashboard、/screenwall等子页面入口可以直接访问,另外浏览器的后退前进,也希望减少重新加载的过程。于是参考Vue的简单路由实现了一下。这个“简单路由”的策略是:

  • 页面内功能跳转时,使用Web的History API的 pushState() 把之前页面(比如//server/home)放到历史里,然后再跳转到目标页(比如 //server/about)。
  • 当使用浏览器的前进、后退时,通过window.onpopstate可以拦截到,通过JS处理功能页面转换就行,不会产生页面重新加载。
  • 另外,通过Web Server的映射,把/home/wall/about等功能页面入口都映射到统一页面(比如index.html)来响应,因为这是一个单页面嘛。

坑在哪儿?这实际是拿多页面伪装了一个单页面应用,因为当用户在URL里直接输入 //server/home 回车时,页面还是重新加载了,哈哈。额外查了查:

  • window.onbeforeunload,这是个页面重加载的入口,但是只能通过返回值给用户一个弹出警告信息,没法做拦截。通常用在防止用户不小心离开一个正在进行的会话页面,常见如微信、网银付款这类的。
  • window.onhashchange,通常SPA都是靠这个入口拦截功能跳转的,这里hash实际是锚点,在形如 /app#home/app#about/app#dev/124 的功能页之间跳转。而在浏览器地址输入不同的#功能hash,不会重新加载页面,只会触发haschange。Vue Route 也是这么做的

也许过一阵把“简单路由”改成“功能hash”方式,或者直接用Vue Route吧。

有趣的坑,多语言支持

用的vue-i18n,比较方便,根据文档说明就行,格式化几个方式都比较好用。坑主要在于浏览器的语言识别。Chrome下的navigator.language是指的浏览器界面语言,如果以用户配置的页面语言优先,就要使用navigator.languages

navigator.language
"zh-CN"
navigator.languages
["zh", "en", "zh-CN", "zh-TW"]

进一步折腾

如果再有比较完整的时间,可能会考虑这几项:

  • App端如何打包、压缩代码?
  • App的自动测试还是要完善起来。
  • 页面的自动测试如何进行?Chrome无头模式跑起来。
  • Webpack过程里,ESLint代码校验这一步目前是跳过了,需要补上。