时光机和回忆之旅

时光机和回忆之旅

今天又用时光机找回以前的博客看了看,终于下定决心要将这些逝去的记忆碎片拼凑回来。
这个随着阿荡的服务器到期丢失的,我的最初的博客。
第一次通过邮件和老外交流;第一次翻译英文文档;第一次使用安卓手机;让电脑通过手机的无线流量套餐上网。

现在看看,还真有一种奇妙的感觉。

归来的技术栈——无状态的服务端

本系列文章都是以方便开发和维护为出发点,在可能的时候,尽量兼顾性能方面的需求

本文是 归来的技术栈 的第三篇,必要时请阅读链接中的内容了解背景。

如果拿 Node.js 和 PHP 比较,我向来是喜欢 Node 多一点。但有一点是 PHP 天然具备, 而 Node 需要付出一些努力才能实现的。

默认情况下,PHP 针对每个请求会使用独立的进程来处理,完成后即销毁。虽然从性能上来说,每次执行都要重复加载文件和数据会带来负面影响,但在另一些方面,却有着先天优势。

比如某个请求发生了错误,不会对另一个请求造成影响;内存泄漏的负面效果无法累积起来影响系统的长期稳定运行;在一定程度上只要堆服务器就可以横向扩展来提高并发能力;不需要针对服务器启停编写额外的代码来初始化和扫尾;服务器意外关闭一般也不会造成什么影响(虽然也不总是如此,比如用了APC之类)。

这些都是无状态带来的好处,可以降低开发和维护的成本,并通过堆硬件提高性能。抛弃一些东西,在其他方面就可以获得更多。

完全无状态的服务端类似一个巨大的纯函数,每个参数相同的请求都会获得相同的结果,也不对外部环境产生影响。但是如果真做到这个程度,服务端就变成静态了,失去其应有的作用。

因此我们还是需要一种手段,在外部保存数据,最好是基于文件的数据库。数据库用内存加速可以接受,但是完全依赖内存就会失去对意外宕机的抵抗力。其他临时性的数据,能省则省。包括 session,虽然 session 也可以用文件或数据库保存,但是我们可以通过使用 json web token ,把它也省了。服务器上的状态越少、维护越容易。

当然,在实际工作中,我也使用缓存,但仅限于特别关键的地方。缓存必须独立于自己开发的软件存在,并且往往是以单向的、即时计算的形式来生成。独立可以确保能在不同进程间共享;单向是指只有一种方法来计算缓存,如果要更新缓存,只能用相同的方法重新计算一次,而不允许对当前的缓存进行加减等操作,这样就算出现意外的并发,也不会导致缓存中的内容出现错误;即时计算是指在第一次需要某个值时,才对其进行计算,并将结果存入缓存,直到过期。如此一来,缓存也成了“无状态”的,缓存的内容不受计算次数的影响,宕机重启也不会影响软件的整体运行,缓存会在需要时各自重建。虽然这种做法没有将缓存的价值发挥到最大,但由于开发和维护成本极低,有很高的性价比。

Node.js 毕竟不是 PHP,要在 Node.js 和 Express 环境中做到完全的无状态有点费力不讨好,但是基本的思想可以借鉴,尽量减少服务端要维护的状态,并在模块的级别实现这一目标。

要做到这一点,最基本的一个要求,就是别让变量活过一个请求,Express 的每个请求最终都会对应到一个回调函数,这个函数里不能出现其他来自外部的变量(必要的第三方库和数据库连接池会出现在其中,但从整个模块角度看,它们应该算是不可变量)。如果你在整个模块中,都能只使用 const,不用 var 来完成所有功能,并且没有作弊(把 const 定义的对象下的属性当变量用),那么就基本达成无状态的目标了。

当你的模块实现了无状态。在性能方面,就可放心的使用 Cluster 来启动子进程扩展并发能力,请求无论落入哪个子进程,都不会影响返回的结果,如果在前面加上一道负载均衡,还可以进一步扩展到多台服务器;在开发和维护方面,你可以放心的使用热加载,通过监测文件变化,在不停止服务的情况下更新软件版本。粒度比使用 nodemon 或者 supervior 等工具更细。

归来的技术栈——正确模块化,express 的 app.use

在 express 4 中,app.use 有如下用法:

1
2
3
4
5
6
7
const express = require('express');
const app = express();
const subapp = express();

app.use('/subpath', subapp);

app.listen(3000);

subapp 做为 express 的一个实例,本身也是 middleware ,可以被 app.use “挂载”到指定路径。

这种用法给我们项目中功能模块的可移植性进一步增加了保证,以之前用到的目录结构为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── app.js
└── src
├── admin
│ ├── stylesheets
│ ├── javascripts
│ ├── index.html
│ ├── client.js
│ ├── riot-tags
│ └── server.js
└── app
├── stylesheets
├── javascripts
├── index.html
├── client.js
├── riot-tags
└── server.js

两个 server.js 文件分别是 admin 和 app 的入口文件。这时我们可以这样做:

/app.js

1
2
3
4
5
6
7
8
const express = require('express');
const app = express();

const admin = require('./src/admin/server.js');

app->use('/admin', admin);

app->listen(3000);

/src/admin/server.js

1
2
3
4
5
6
7
8
const express = require('express');
const app = express();

app->get('/login', ...)
app->post('/api/login', ...)
...

module.exports = app;

这样,我们可以通过GET /admin/loginPOST /admin/api/login 来访问 admin 模块中的方法了。 在这种设计下,admin 目录中的内容自成一体,不依赖 /app.js 向它传入参数。可移植性就更好了。

由于 /src/admin/server.js 中的 app 是一个完整的 express 实例,因此它本身也可以通过 use 方法来使用 middleware。并且这个 middleware 的有效范围也会被控制在 admin 中,而不会干扰其他模块。所以这也是一种控制 middleware 有效范围的简便方法。

这个方法将模块和主程序完全解偶,如果你再进一步,将 admin 模块做成无状态的,那么就可以安全的实现热更新了,这将大大提高开发效率。

归来的技术栈

所有的设计都是从开发者面对的问题出发。

我要用有限的人手(就当是我一个人吧)进行全栈开发,中小型项目。从开发角度讲,要同时保证开发效率和业务模块的可移植性;从运行的角度讲,需要较高的可靠性但对性能要求不高。

向来是个全栈程序员。自从上次换了工作,到现在三年多。后端一直在用 PHP 和 Slim 框架,Composer 进行包管理,MySQL 做数据库,开发 restful 风格的接口,四平八稳;前端页面做成 SPA 形式,因缘际会用了 riot.js,理念和现在当红的 react 和 vue 相似,功能稍弱,但学习成本也低。

这个理念,正是这次想要更换技术栈的主要原因,上个月和朋友聊天时,说起了以前为什么会选择 riot.js,为了 riot.js 官网里的两句话:

We should focus on reusable components instead of templates
Templates separate technologies, not concerns

这个观点其实是 react 的开发者提出来的,我们要关注的是可重用的组件而不是模版,模版的概念是从技术角度出发的,而真正需要我们关心的是业务。第一次看到这个观点时,我是震撼的,因为那时我正在用 Angluar 1,老是要在文件(模型、视图、控制器)之间来回切换和定位,总觉得现状有问题,却又不知道如何改变。

那天聊完之后,我突然意识到,同样的理念可以走的更远一点,不用限定在组件的级别,用在业务模块的级别上也是可行的(尤其是对全栈开发者和中小型项目来说)。我们之前的项目根据前后端来划分目录结构,也是一种从技术角度出发的选择,而这让我遇到了一些问题。

先来看看以前的项目结构(隐藏了一些不必要的细节)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── app
│ ├── lib
│ │ ├── admin
│ │ └── app
│ └── router.php
├── lib
├── pages
│ ├── admin.html
│ └── app.html
├── public
│ ├── images
│ ├── javascripts
│ └── stylesheets
└── riot-tags
├── admin
└── app

/app/lib 目录下是后端的逻辑,/pages 目录下是前端入口页面(模版,通过PHP调用展示),/riot-tags 下是前端文件,其他前端需要用到的图片、公共样式和库都在 /public 下面。

假设另一个项目需要用到 admin 模块,我只能分别将 /app/lib/admin/pages/admin.html/riot-tags/admin 拷贝到新项目里去。但是 /public 目录下的内容就比较麻烦了,不同模块用到的东西都混在一起,已经很难分清哪个被谁用了。如果再在这些目录下按模块分目录,又会变得非常繁琐。

这样分配目录还会对开发者的心态产生潜移默化的影响,两个模块的后端文件之间靠的太近,会增加相互调用,写出高耦合度代码的倾向。

再之前的工作是开发一个长期维护的平台,没有这方面的考量,而现在经常遇到将之前开发的系统中的功能挑几个出来,做成一个新系统的需求,因此模块的独立性和可移植性变得非常重要。

新的目录结构类似这种形式(隐藏了一些不必要的细节):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── app.js
└── src
├── admin
│ ├── stylesheets
│ ├── javascripts
│ ├── index.html
│ ├── client.js
│ ├── riot-tags
│ └── server.js
└── app
├── stylesheets
├── javascripts
├── index.html
├── client.js
├── riot-tags
└── server.js

除了目录之外,现在的技术栈也存在其他方面的问题,由于同时用了 PHP 和 JavaScript 两种主要的开发语言,在小公司难招人的情况下,招两个都会的人更难;需要用到两个包管理工具,开发环境、运行环境、开发工具配置什么也都要搞两种;虽然已经把服务端搞得很瘦,常用逻辑的代码生成器也做了;但同时要维护两套东西确实不够DRY的。说到底吧,还是人不够。

所以,只能尽可能把技术栈缩小,另外通过把粒度做粗一点、牺牲一定的性能来换取开发时的便利和运行时的稳定。

于是,在国庆假期的时候,时隔三四年,再次拿起 node.js 和 express,是为“归来的技术栈”(不知道为什么突然想起了《归来的奥特曼》,挺应景的,所以取了这么个标题)。

这次把整个结构从头梳理了一遍,保留之前的成功之处,再引入一些新鲜血液。整个过程下来,积攒了不少东西,在此和大家分享。当然,由于各自面对的问题和环境不同,你不一定认同我的某些观点,但我仍希望能带给你一些有意思的东西。

先丢几个我非常认同的观点:

代码是写给人看的,其次才是给机器执行
软件的维护成本非常重要,软件生命周期越长越重要,直到变成最重要的
开发人员的时间比机器的更宝贵
写博客比写代码更花时间

这个系列会包含以下内容:

  • 正确模块化,express 的 app.use
  • 无状态的服务端
  • json web token
  • 一个小坑 - app.use 和 unless 的 useOriginalUrl
  • 无状态的客户端
  • 单页面应用和路由 - navigo
  • 如何保存和校验密码 - bcrypt
  • 打通前后端,简化操作 - pouchdb
  • 打通前后端,简化操作 - restlike
  • 简化开发流程,后端自动重启和热更新
  • 简化开发流程,前端热更新 - webpack

Svg Filter 相关的几个问题

在用 Leaflet 处理 GeoJSON 格式的地理信息时,解析结果被以内联的 SVG 格式嵌入网页中。为了使地图更有质感,希望通过添加阴影获得视觉上的高低效果,尝试过程记录如下。

css

首先,<svg> 内的元素是支持 css 样式的,用法也和普通的 css 一样,可以通过 id 或 class 来套用样式,但是 svg 元素具体支持的规则和普通 DOM 元素相差很大

box-shadow

box-shadow 不支持 svg 内部的元素

filter

filterdrop-shadow 也可以制造阴影效果,但是目前只有 Edge 支持对 svg 内部元素使用

元素

1
2
3
4
5
6
<defs>
<filter id="shadow-water">
<feGaussianBlur out="blurOut" in="SourceGraphic" stdDeviation="5"></feGaussianBlur>
<feBlend in="SourceGraphic" in2="blurOut" mode="normal"></feBlend>
</filter>
</defs>

<filter> 本身就是 svg 标准的一部分,也是目前兼容性最好的,为 svg 元素添加阴影的方法,但是其本身并没有提供一个直接的添加阴影的方法,上面的例子里是通过将模糊后的图像和原始图像叠加实现的

<defs> 需要放置在 <svg> 元素内

需要使用此效果的元素,需要设置属性 filter=”url(#shadow-water)” 来应用

svg 元素的操作

在我的环境里,svg 是由 leaflet 生成的,因此自定义的 <defs> 只能通过代码动态的插入到已有的 <svg> 标签内,但是这里不能使用 jQuery 来操作,类似

1
$('svg').prepend('<defs> <filter id="shadow-water"> <feGaussianBlur out="blurOut" in="SourceGraphic" stdDeviation="5"></feGaussianBlur> <feBlend in="SourceGraphic" in2="blurOut" mode="normal"></feBlend> </filter> </defs>');

是行不通的,因为 <svg> 内的元素类型需要是 SVGElement 才能正常工作,而 jQuery 会将 defs 等元素当作普通 DOM 元素加入到 svg 内部,因此会出现从调试工具里看到代码和手动编写的 svg 文件一致,但实际上无法发挥作用的情况。

这里有一篇文章描述了这个问题,并提供了一个动态创建 svg 元素的方法

两个 svg 标签

但是上面提到的方法写起来比较麻烦,尤其是需要动态插入的标签数量比较多,并且有嵌套的情况。

最后我尝试了将 defs 写在另一 svg 里来解决这个问题。一个 svg 里的元素是可以引用另一个 svg 里的 filter 的,我预先创建了一个只包含 defs 的 svg 标签,内部只包含了几个需要用到的 filter 效果,而没实质性的元素。这个 svg 默认也会占据一定的屏幕空间,150x300,因此需要加上样式将其隐藏。

隐藏提供特效的 svg 标签

首先尝试 style=”display: none”,svg 标签被隐藏了,但是特效也一起消失;
然后尝试 style=”width: 0; height: 0”,特效得以保留,但仍占据了屏幕大约一行文字的高度;
最终改成 style=”position: absolute; width: 0; height: 0”,问题解决

Atom 编辑器 PHP 调试配置

环境

  • macOS 10.12
  • php 7.1
  • atom 1.16
  • brew 1.2

安装和配置

xdebug

打开命令行终端,输入以下命令

1
brew install php71 php71-xdebug

brew 是 macOS 下的包管理器,php71 是 php7.1 的包名,如果你使用其他系统,请自行替换成相应的命令

配置

xdebug 的配置在 /usr/local/etc/php/7.1/conf.d/ext-xdebug.ini 中[注1],增加下列配置

1
2
3
4
5
6
7
xdebug.remote_enable=1
xdebug.remote_host=127.0.0.1
xdebug.remote_connect_back=1 # Not safe for production servers
xdebug.remote_port=9332
xdebug.remote_handler=dbgp
xdebug.remote_mode=req
xdebug.remote_autostart=false

这里将 remote_port 设置成 9332 是为了避免和 php-fpm 的默认端口 9000 冲突,你也可以自行选择一个端口,但是必须和下面的 php-debug 插件中指定的端口一致

remote_autostart 设置成 false 可以配合浏览器插件,按需开启调试功能。

[注1]

如果你的配置文件不在这个位置,可以在命令行终端中输入

1
php -i | grep with-config-file-scan-dir

找到配置文件所在目录

php-debug

打开插件安装界面,搜索 php-debug

php-debug

点击 install 安装

配置

安装完毕,点击 Settings,打开 php-debug 的配置界面
找到 Server Port 选项,默认是 9000,改成 9332,和 xdebug 的 remote_port 一致。
server port

php-debug 安装完毕后,atom 的左下角会有一个带虫子图表的 PHP Debug 按钮,点击展开,在五个调试按钮的右边可以看到 Listening on port 9332…,如果你看到的还是 9000,说明 Server Port 的修改尚未生效,可以尝试重启 atom 编辑器。

xdebug-helper

xdebug helper 是一个 chrome 插件,让我们可以选择性的开启 xdebug 的代码调试和性能调优工具,可以在 php-debug 的配置页面找到它的链接,也可以直接在 chrome 商城查找。

配置

安装完毕后,chrome 地址栏右侧会多出一个虫子图标,右键点击,进入选项,在 IDE key 选择 other,右侧输入框填写你的 key,然后点击 save 保存。

输入图片说明

key 是怎么来的呢?在命令行终端输入:

1
php -i | grep IDE

会得到类似:

1
IDE Key => xiongliding

右边的值就是你的 key 了。

php -i 可以理解为命令行版的 phpinfo 页面。

对于 macOS 和 brew 用户,安装 xdebug 时会自动以你的用户名为 key。

测试

新建一个目录,创建一个简单的 hello.php 文件:

1
2
3
<?php
echo "Hello, World";
`

将命令行终端切换到该目录下,输入

1
php -S 0.0.0.0:8888

这时用浏览器访问 http://127.0.0.1:8888/hello.php 可以看到 Hello, World

现在,点击编辑器行号右边的位置设置断点,可以看到一个小圆点,行号变绿。

输入图片说明

切换到浏览器,左键点击 xdebug helper 的虫子图标,在弹出菜单选择 debug 。

输入图片说明

刷新浏览器,浏览器会保持在加载状态,回到编辑器,可以看到断点所在行已经高亮显示,下方 PHP Debug 的五个按钮也都变成可用状态,原来显示 Listening on port 9332… 的位置变为 Connected

输入图片说明

现在,你可以进入自己的项目目录,通过 php -S 建立临时的 php 服务来调试自己的代码了。如果原来使用的是 php-fpm ,那么重启 php-fpm 服务,也可以使调试在自己的项目中生效[注2]。

[注2]

默认前提是你的 php-fpm 和 php-xdebug 是用相同的包管理器安装的对应版本。

大规模数据上传的问题和解决

过完年第一天上班,就接到一个项目连夜出差去了。客户手头有大约 20 万条住房信息需要上报到一个指定的系统。这 20 万条记录由 200 多个员工花了超过一个月的时间到该县辖区内的各自然村挨家挨户上门采集,然后以乡镇-行政村-自然村的结构存放在 1000 多个 excel 文件中。excel 中的每条记录对应一套住房,同目录下,还有房屋的照片。

这些信息和图片都要上传到一个指定的网站,当然,没有接口。

所以,问题还是很清晰的,就是实现一个爬虫模拟请求,先登录网站,然后从 excel 中依次读取记录,将相应的信息和图片进行上传,重复这个过程。

常规问题

技术选择

其实没什么好选,我最熟悉的语言是 PHP 和 JavaScript,最终选了 Node.js 。平常处理网络请求已经用的很多,处理数据的话有 underscore ,模拟请求有 superagent ,处理 dom 内容有 cheerio ,想要控制队列和并发有 async ,处理 excel 有 xlsx 。性能和开发速度也有个很好的平衡。

验证码和保持会话

只要网站是需要登录的,做爬虫就会遇到这两个问题。验证码简单可以用算法识别,验证码复杂就人工介入,遇到验证码后下载到本地,然后弹出对话框等待人工识别后回填。会话保持的话 superagent 本身就有 cookie 机制,不是问题。

表单分析

每个请求需要发送什么内容,有哪些参数,如何和原始数据对应,这个工作琐碎而费神,好在有同事帮忙处理。

大局观

之前提到的问题都是纯粹的技术问题,以往的经验基本都已经覆盖到了,下面要讲的则是我在这个项目中最深刻的感悟。这些概念其实很早就接触过,或隐约有体会,但这次是深深的体验了一把。

每次只做一件事

顺利的情况下,上传一条记录包括下列步骤:读取一条数据、预处理一条数据、上传一张图片、拼接表单、发送请求、记录日志。

理想情况下,我可以先实现一个处理一条记录的方法,然后在外面套一层循环,问题就解决了。但事实上,每个步骤本身都很复杂,想要一步不错的走完这个流程还是有难度的,预处理时会发现数据不合要求、上传图片可能由于网络原因失败,代码质量的问题也可能导致程序意外终止。

更合理的做法是把整个流程用几个相对独立的小程序来实现,一个程序用来压缩图片,一个程序用来上传图片,一个程序用来检查数据质量,一个程序用来上报数据。就像 Unix 的管道一样,上一个工具的输出,可以作为下一个工具的输入。

比较值得一提的一点是,这几个独立程序的输出,必须以文件的形式保存下来。

这样做最大的好处就是让问题显而易见,每经过一个步骤,你都可以对产物进行检查,有没有出错一目了然。压缩失败,目录里的文件会缺,上传失败,上传结果汇总表的数据会比文件数量少,这比看程序运行时内存里的数据容易多了。

这些产物本身也是日志,比如上传时部分成功,部分失败,下一次上传的时候,可以根据上一次上传的结果,跳过已经成功的部分,只补传上一次失败的,简单的续传就实现了。这一点在数据量大的时候尤其重要,节约的时间非常可观。

不要相信用户

做网站的时候,出于安全的考虑,有一个原则叫做不要相信用户,因为总有人想要搞破坏,绕过你的防线。

我发现处理大量数据的时候也存在这个问题,你不能相信用户给的数据是符合要求的。这里,用户也想把数据质量做好,但确实无法做到。首先数据来源不一,要求 200 个人采集来的数据格式完全相同几乎是不可能的,即使经过培训,有统一模版,还有专门的宏来解决一些重复操作,也不可能。

一开始用户说我们负责程序,他们负责保证数据正确,我信了,结果我在加班。增加了一个功能,专门检查数据的正确性。

垃圾进,垃圾出 Garbage In Garbage Out GIGO

之前说到我们负责程序,他们负责数据正确性,看上去责任很明确。为什么我还是要做检查数据正确性的工作呢。一方面是为了互相配合更好的完成工作,另一方面是因为数据量大了以后,错误很难定位。

具体到这个项目中,在上传过程中,有一个地方需要将 excel 中的多条记录合并上传,比如根据身份证判断为同一个人,他名下的房屋要合并成一条记录后再上传,但实际上数据里存在两个人填了同一个身份证或者里面填写的房屋数量和实际条数不符的情况。

出现上述问题的情况下,按照正确数据格式设计的合并功能将无法正确工作,最终导致上报到系统中的条数和原始数据条数不一致。

那么问题来了,我如何证明程序是对的,错是在数据呢。从几万条数据里找到那条出错的数据,然后告诉客户“你给的这条数据有问题”吗?

世界上有很多东西,要证明正确是很难的,要证明错误却容易的多,程序就是其中之一。

最后一点,考虑的全面一点

这个太难了,经验会让你考虑到更多问题,却总是不能全面。这也是我写这篇文章的目的之一,对遇到的问题进行总结,下次多少能思考的更全面。

关于这个项目,最有意思的是,我没有预先考虑到要做删除数据的功能。程序唰唰唰一跑,几千几万条数据就上去了,如果出错了,根本定位不到哪些条目是错的,尤其是测试的时候,刷了一片数据上去。手动删可真要删到翻白眼了。

做一个批量删除吧。

针对微信的前端文件版本管理原则

之前的文章里,我们讲到了微信的一个问题:缓存它认为是静态内容的文件,比如 javascript、css、html 还有图片。这给使用 Android 系统的开发人员带来了不便,因为 Android 中的微信没有刷新按钮,改完代码后想再测试很不方便。

但这是个契机,放大了一个被很多开发人员忽略的问题:前端文件是会被缓存的。不少开发人员甚至从未意识到这一点,因为在日常开发过程中,每次改完代码都会主动刷新一下再测试效果,如果不行就强制刷新一下,从来没有考虑普通用户会遇到什么问题。普通用户并不会在你的新代码上线后就刷新一下浏览器,知道强制刷新的就更少了,导致部分用户仍在用旧版的前端代码和你新版的后端交互,这经常会导致一些奇怪的 bug,而且是你永远无法查出来的 bug 。

因此,无论是不是针对微信,前端文件的版本管理都是十分重要的。

避免过时的缓存

浏览器缓存是一个非常有用的机制,可以明显减少浏览器和服务器间的数据传输,加快网页的加载速度,也能大大减少服务器的压力。但是缓存一旦过期,就会起到反作用。

有人会使用简单暴力的方法,通过在服务器配置文件头,告诉浏览器不要缓存某些文件,但这样做会牺牲缓存可以带来的好处,而且这一套在微信浏览器里完全行不通,它根据文件后缀来决定是不是要缓存,不管你的服务器是怎么跟它说的。

因此我们只能在迁就微信的情况下,合理的使用缓存,充分利用缓存的优点,同时避免缓存过期的问题。

两个原则
一、绝对不要缓存入口文件

除非你有信心,永远不需要修改这个页面以及所有被它使用的资源,否则绝对不要缓存入口文件。即使你对自己的代码有信心,你也很难保证需求不变化,呵呵。所以,绝对不要缓存入口文件

入口文件和其他文件是不同的,不能随意地变更地址,入口地址放在微信菜单里,会有最长 24 小时的生效时间,如果是分享类的活动,中途想换地址就更不可能。

实际操作中的规则,就是永远不要把入口文件命名成 .html 结尾。微信会无条件缓存 html 文件,导致你后续的修改无法传达到之前访问过的用户手中,他们会在很长一段时间里,只能看到旧版。

如果你的入口文件是用 php 之类的动态生成的,那么不要把地址重写成 .html 就好。

如果你的入口文件真的是静态的 html 文件,那么你可以考虑用你服务器端的编程语言加载并原样输出这个文件(以 php 为例):

1
2
<?php  
echo file_get_contents('index.html');

针对支持 php 的服务器,更简单的方法是直接将文件后缀改成 .php,asp 和 jsp 也类似。

如果你对性能有更高的要求,更好的方法是将文件后缀改掉,然后添加一个相应的 Mime 类型。例如将文件改成 index.nocache ,然后在 mime.conf (以 nginx 为例)里为 text/html 类别添加一个新后缀。

1
2
3
4
types {  
text/html html htm shtml nocache;
....
}

这样服务器就不必调用第三方的程序(如 php-fpm)尝试解析了,访问量大的时候也可以节约一些性能。

二、用版本管理其他资源

除了入口文件,所有其他资源都应该使用版本进行管理,主要包括 javascript、css、web 字体、还有图片。版本号可以包含在文件名里(如 app-0001.js,或者查询参数中(app.js?0001),两者各有好处,我个人更喜欢后者。

对版本号有两个要求:内容不同的文件,版本号一定是不同的;版本号不同的文件,内容一定是不同的

第一个要求用来避免用户使用过期的缓存。如果你改了内容,却不改版本号,用户就会继续使用缓存中的旧版本。

第二个要求用来避免缓存的浪费。因为你每次修改版本号,用户就需要重新加载一次那个文件,如果文件内容明明没有改变,缓存里的已经够用,你却更新了版本号,会让用户白白多下载一次。

因此,使用文件的哈希值来作为版本号是非常合适的,哈希值以文件内容作为计算依据,并且在有限的样本空间里,可以认为内容和哈希值是一一对应的,满足上面所说的两个条件。用哈希值还可以很方便的自动化,避免人工命名,我们都清楚,人迟早是会犯错的。

示例

结合上述两个原则,我的入口文件会被命名为 index.php ,简化后的内容如下:

1
2
3
4
5
6
7
8
9
10
<!doctype html>  
<head>
<title>缓存管理</title>
<script src="/app.js?4f05b2a7"></script>
...
</head>
<body>
...
</body>
</html>

下周,我们继续聊聊如何将今天提到的原则应用到实际的项目中,实现版本号的自动化管理,以及如何解决版本号带来的测试方面的问题。

用加载画面改善用户体验

毋庸置疑,单页面应用(Single Page Application,以下简称 SPA)正在变得越来越流行。SPA 具有前后端分离、接口重用度高、操作响应快速等优点,在用户体验上相比传统的网站也更接近原生应用。但与此同时,SPA 的体积却在不断增大,导致加载所需的时间越来越长,而这个问题在微信里面特别突出。

微信中的加载问题

首先,微信通常安装在移动设备上,网络的速度并不像固定宽带那么有保障,虽然 4G 已经普及,但总有些地方或某些时候网络没那么理想。在网络状况不佳时,加载较大的 SPA 耗时较长,屏幕表现为长时间白屏。

然后,微信对网页的缓存机制跟一般的浏览器不同。即使你没在服务器上进行特别的配置,它默认就会缓存 html、css、javascript 等它认为是“静态”的内容,而且在缓存有效期间“拒绝”去服务器上看看是否有更新,给不少新接触微信开发的程序员造成困扰。反过来,当你希望它将某个内容长期缓存,并通过响应头部的信息明确告知时,它也不能很好的遵守。说实话,你没法知道缓存什么时候会过期。不过也不能全怪它,微信毕竟不是浏览器。

结果,我们必须面对的事实就是经常会遇到这样的情况:用户打开你的公众号,点击某个按钮进入了你开发的 SPA,要加载的东西不少、网络又不太好、而缓存又都过期了,用户等待了 5 秒钟,页面还是空白的,用户关闭了页面并觉得你的网站很糟糕。

在对比 SPA 和原生应用时,常常会把“不需要下载和安装”作为一项优势,但我们都清楚,其实每次使用前都要“下载”一次。

加载画面

加载画面本身不能加快网站的加载速度,甚至会稍微拖慢一点,但它是一个信号,告诉用户这个网站并没有挂掉,他已经打开了这个网站,只是还要等待一会才能使用。这会让用户更有耐心继续等下去,尤其是在你能提供一个进度条的时候。

既然决定要在应用里加入加载画面了,那么一定要第一时间让它出现,并且随着网页内容的加载随时汇报加载进度。

显示加载画面

先来看一个没有加载画面的网页简化之后的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!doctype html>  
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>

<script src="/javascript/require.js"></script>
<script>
// 通过 require.js 来加载其他 javascript 文件
</script>
</body>
</html>

在上面这个例子中,样式文件放在 head 中,脚本文件放在 body 的尾部,是一种推荐写法,能让网页在第一时间进行显示,然后再加载 javascript 来对网页进行渐进式的增强。

但是这个做法对于 SPA 的帮助不是很大,现在大多数 SPA 都是基于 Angular、React、Vue 等前端框架,其内容都是通过 javascript 动态生成后再显示出来的。

这里我们假设网站已经使用 require.js 实现了按需加载和多线并行加载,但用户对加载速度仍不满意(这就是我遇到的问题和写这篇文章的原因)。

为了让加载画面能第一时间显示,我必须放弃利用其他框架,写成一个零依赖的库。它的位置应该同样位于 body 的尾部,但在所有其他 javascript 文件之前。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!doctype html>  
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>

<script>
var splash = document.createElement('div');
splash.setAttribute('style', 'display: flex; justify-content: center; text-align: center; flex-direction: column; position: fixed; top: 0; left: 0; width: 100%; height: 100%; color: #fff; background: #288198');
var title = document.createElement('div');
title.textContent = 'WebCraft';
var progress = document.createElement('div');
progress.textContent = '开始加载';
splash.appendChild(title);
splash.appendChild(progress);
document.body.appendChild(splash);
</script>
<script src="/javascript/require.js"></script>
<script>
// 通过 require.js 来加载其他 javascript 文件
</script>
</body>
</html>

写完后,通过浏览器进行观察,方法是调出 Chrome 的开发者工具,打开 Network 标签,勾选 Disable Cache 禁用缓存,在 Throttling 处选择 GPRS,这样浏览器就会模拟 GRPS 网络的速度来加载文件。

可以观察到,加载画面在加载 require.js 之前就显示了,但是暴露出另一个问题,在样式文件加载完之前,网页一直是空白的,这是因为浏览器在加载完样式文件之前,是不会进行渲染的。

这让我想起了早期的 Opera 浏览器(换核之前),加载样式文件是不会阻止渲染的,它会直接显示出一个没有样式的网页,在加载完样式文件后再渲染成有样式的样子。

在新的标准中,link 标签将会有一个可选的 preload 属性,允许浏览器在不加载完这个样式文件的时候就进行渲染,但目前很多浏览器尚未支持,微信在 Android 中使用的 X5 内核,以及 iOS 的 Safari 也都不支持。

我目前开发的大多数的项目都需要加载 bootstrap、font-awesome 这两个体积稍大的样式库,还有一些根据项目需要另外引入的零碎的库。加在一起要加载的量也是不小的。

为了让加载画面出现的更快,样式文件必须下移到显示加载画面的 script 标签之后。但是很不幸,微信内嵌浏览器使用的内核,无论是自己的 X5 还是 iOS 的 Safari 内核,还有一个特性,就是只要遇到加载样式文件的 link 标签,就会停止渲染,直到样式文件加载完毕。

也就是说,即使把 link 放在 script 之后,也无法先显示加载画面。

现在就剩下一条路了,动态加载样式文件,听起来高大上,实现起来很简单,用 javascript 创建一个 link 标签,指定好要加载的样式文件,再挂到 DOM 树中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!doctype html>  
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>

<script>
var splash = document.createElement('div');
splash.setAttribute('style', 'display: flex; justify-content: center; text-align: center; flex-direction: column; position: fixed; top: 0; left: 0; width: 100%; height: 100%; color: #fff; background: #288198');
var title = document.createElement('div');
title.textContent = 'WebCraft';
var progress = document.createElement('div');
progress.textContent = '开始加载';
splash.appendChild(title);
splash.appendChild(progress);
document.body.appendChild(splash);

var link = c('link');
link.setAttribute('href', '/css/bootstrap.css');
link.setAttribute('rel', 'stylesheet');
document.body.appendChild(link);
</script>
<script src="/javascript/require.js"></script>
<script>
// 通过 require.js 来加载其他 javascript 文件
</script>
</body>
</html>

经过这一次的修改,加载画面就可以在只加载了 html 文件的情况下显示啦。接下来我们要实现显示加载进度的功能。

显示加载进度

首先,我们要明确在第一个主要内容页面显示之前,总共要加载多少东西,或者说有几个节点,这样我们就有了一个计数的目标;然后创建一个空的计数器,每完成一个节点,计数器加一,并更新进度;最终,当计数器到达计数目标的时候,加载完成,隐藏掉加载画面,显示出内容。

1
2
3
4
5
6
7
8
9
10
var total = 节点数;  
var count = 0;
var loaded = function () {
count++;
if (count < total) {
// 更新加载进度
} else {
// 隐藏加载画面
}
}

total 是计数目标,count 是计数器,每次完成一个节点,调用 loaded 函数就可以更新计数。

现在,我们暂不考虑应用本身的逻辑,只把文件的加载纳入进度显示中,那么在上面的例子中有两个需要加载的文件,一个是 bootstrap.css ,另一个是 require.js 。对于 JavaScript 文件,由于是顺序加载和执行的,实现起来非常简单:

1
2
3
4
<script src="/javascript/require.js"></script>  
<script>
loaded();
</script>

如上所示,require.js 加载和执行完毕后,就会执行 loaded() 使计数器加一。

对于样式文件,我们通过监听 load 事件来实现:

1
2
3
4
5
var link = c('link');  
link.setAttribute('href', '/css/bootstrap.css');
link.setAttribute('rel', 'stylesheet');
link.addEventListener('load', loaded);
document.body.appendChild(link);

回到现实的情况中,通过 require.js 加载的、运行 SPA 必备的库也可以作为节点,通过在 require 函数的回调中加入 loaded 来监控进度。

1
2
3
4
require(['jquery'], function () {  
loaded();
// 继续处理
});

如果需要,还可以进一步将进度监控到获取渲染第一个页面所需的数据的步骤,但是别忘了将计数目标做出相应改变。

1
2
3
4
$.get('/api/some-data', function (data) {
loaded();
// 渲染第一个主要内容页面
});

完整的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<!doctype html>  
<html>
<head>
<meta charset="utf-8">
<title>loading demo</title>
<link href="/css/bootstrap.css" rel="stylesheet">
</head>
<body>
<app></app>

<script>
var splash = document.createElement('div');
splash.setAttribute('style', 'display: flex; justify-content: center; text-align: center; flex-direction: column; position: fixed; top: 0; left: 0; width: 100%; height: 100%; color: #fff; background: #288198');
var title = document.createElement('div');
title.textContent = 'WebCraft';
var progress = document.createElement('div');
progress.textContent = '开始加载';
splash.appendChild(title);
splash.appendChild(progress);
document.body.appendChild(splash);

var total = 4;
var count = 0;
var loaded = function () {
count++;
if (count < total) {
progress.textContent = count + '/' + total;
} else {
splash.remove();
}
}
var link = c('link');
link.setAttribute('href', '/css/bootstrap.css');
link.setAttribute('rel', 'stylesheet');
link.addEventListener('load', loaded);
document.body.appendChild(link);
</script>
<script src="/javascript/require.js"></script>
<script>
loaded();
require(['/javascript/jquery.js'], function ($) {
loaded();
$.get('/api/some-data', function (data) {
loaded();
$('app').append(data);
});
});
</script>
</body>
</html>

至此,一个简单的,可显示进度的加载画面的原型已经完成。本文的主要内容也就到此为止了。

尾声

如果要应用到生产环境中,还有几个问题必须解决:动态添加样式的几个语句应该定义成一个函数以使加载多个样式文件更方便;暴露在全局中的变量太多,不利于维护;样式固定,无法适应不同风格的应用。

持续更新的演示地址:
https://bnlt.org/demo/loading/index.html