CSS和文档

  • CSS的出现和特点

  • <link><style>@import

  • CSS注释

  • 内联样式

  • @import必须写在CSS文档的开头

选择器

  • 基本结构

  • 元素选择器(分组选择器、通配选择器)

  • 类选择器/ID选择器

  • 属性选择器(具体属性、部分属性)

  • 后代选择器、兄弟选择器(>, +

  • 伪类/伪元素选择器

  • ^=, $=, ~=, *=, |=用在属性选择中的部分匹配里,eg:span[class~="bar"]
    其中~=匹配空格隔开的字符,*=匹配部分字符串,|=匹配完整字符串或以字符串开头

  • 常用的伪类选择器有

    • :link 拥有href属性的未访问地址
    • :visited
    • :focus 当前获得输入焦点的元素
    • :hover
    • :active 被用户激活的元素
    • :first-child 第一个子元素,类似地还有:last-childnth-child()
    • :lang() 根据语言选择
    • :first-letterfirst-line针对元素第一个字母和第一行文本
    • :before:after

层叠关系

  • 优先级顺序(ID > 类、属性、伪类 > 元素、伪元素 > 通配或结合符, > 继承的属性)

  • 内联样式和!important的特殊性

  • 继承

  • 层叠规则

  • 来源权重关系(读者重要声明 > 创作者的重要声明 > 创作者正常声明 > 读者正常声明 > 用户代理声明)

  • LVHA(:link - :visited - :hover - :active的声明顺序)

  • 一个声明出现的越后,它的权重越大

值和单位

  • 数字

  • 百分数

  • 颜色(具名、rgb/rgba、十六进制)、Web安全颜色

  • 长度单位(in/cm/mm/pt/pc/px,em/ex/rem/vw/vh/vmin/vmax)

  • URL(url(protocol://server/pathname)url(pathname)

  • 关键字

  • 角度/时间/频率

  • CSS2.1中有一个所有属性共有的关键字:inherit

字体

  • 通用字体(serif/sans-serif/monospace/cursive/fantasy)

  • 指定字体

  • 字体加粗(100~900,lighter/bolder)

  • 字体大小(xx-small ~ xx-large;绝对大小;百分数)

  • 字体风格和变形(font-style,font-variant)

  • font属性([<font-style> || <font-variant> || <font-weight>]?<font-size>[/<line-height>]?<font-family>

  • font-face规则(font-familyfont-stylesrc等)

  • 衬线字体包括Times,Georgia;非衬线字体包括Helvetiva,Geneva,Verdana,Arial;Monospace字体包括Courier,Courier New

  • 字体名称中包含空格或特殊字符时,建议用引号包裹

  • 一般地,400对应normal,700对应bold

  • 字体大小是可以继承的,不过继承的是计算值而不是百分数

  • italic是单独的字体风格,oblique则是正常文本的倾斜版本

  • small-caps表示小型大写字母

文本属性

  • 缩进(text-indent

  • 水平对齐(text-align

  • 垂直对齐(line-heightvertical-align

  • 字间隔和字母间隔(word-spacingletter-spacing

  • 文本转换(text-transfrom

  • 文本装饰(text-decoration

  • 文本阴影(text-shadow

  • 空白符处理(white-space

  • 文本方向(directionunicode-bidi

  • text-indent为负值时表示悬挂缩进效果

  • line-height有继承性,表示文本基线(baseline)间的距离,继承的仍然是计算值

  • 行内元素的行框由行间距和内容区组成

  • vertical-align只应用于行内元素、替换元素和单元格,且不能继承。它可以取百分数和长度值,相对于自身line-height计算。

    • 在基线对齐时,将元素的底部和行框的基线对齐
    • 取值为subsuper时,元素的基线(或底端)将升高或降低
    • 取值bottomtop时,相对行框的顶端和底端
    • 取值为middle时,会把行内元素的中点与行框基线上方0.5ex(约为0.25em,因x-height而异)处对齐
    • 取值为数值时,会相对于父元素行框基线升高
      最后,父元素的行框的行高会因此做调整
  • text-transformuppercase, lowercase, capitalize等几种选择,有继承性

  • text-decorationunderline, overline, line-through, blink等几种选择,没有继承性

  • text-shadow先确定阴影颜色,前两个长度值确定偏移距离,第三个值确定模糊半径,可以同时设置多个阴影效果

  • white-space行为如下表

    空白符 换行符 自动换行
    pre-line 合并 保留 允许
    normal 合并 忽略 允许
    nowrap 合并 忽略 不允许
    pre 保留 保留 不允许
    pre-wrap 保留 保留 允许

盒模型

  • 基本框与包含块

  • 正常流/非替换元素/替换元素/块级元素/行内元素

  • 水平属性(widthmargin/border/padding-left/right

  • 垂直属性(heightmargin/border/padding-top/bottom

  • 行内元素(em框、内容区、行间距、行内框、行框)

  • 元素的显示方式(displayinline-blockrun-in

  • 在水平属性中只有width和外边距可以设置为auto,其余属性必须设置为特定的值或默认为0。使用auto将会弥补实际值和所需总和的差距,当格式化属性过度受限时,会强制把margin-right设置为auto。相反,不止一个auto出现时,若width不为auto,则会将元素居中,出现三个auto时,外边距都会设置为0。

  • 垂直属性类似上面,不一样的是,元素间的外边距会合并,留下较大的外边距。

  • 对于非替换元素,元素行内框高度等于line-height的值;对于替换元素,则由内容区高度决定

  • 行内元素的边框边界由font-size决定,与line-height无关。类似,内外边距不会影响行框的形成和布局。

  • 行内替换元素并没有自己的基线,所以说相对较好的方案是将其行内框底部和基线对齐。

  • inline-block元素的width未定义或声明为auto时,元素会收缩来适应到框宽度刚好足够包含该内容

边距和边框

  • 基本元素框

  • 外边距(负外边距和垂直外边距合并)

  • 行内元素的外边距

  • 边框

  • 内边距

  • 行内元素的内边距

  • 外边距的空白不能放置其他元素

  • 内外边距设置为百分数时,相对于父元素的宽度计算,这样做是为了避免高度上导致无限循环

  • 对于行内元素,只有line-heightfont-sizevertical-align可以改变元素行高,为替换元素设置的外边距会影响行高

  • 边框的默认属性为none medium <color>

  • 元素的背景会延伸到内边距

  • 左内/外边距应用到行内元素开始处,右内/外边距应用到行内元素结束处

背景与颜色

  • 前景色(color

  • 背景色

  • 背景图片

  • 背景重复、背景定位(background-position

  • 背景大小(background-size,CSS3新增)

  • 一般来说,前景包括元素的文本和边框

  • 前景色属性可以继承

  • 所有背景属性都不可继承

  • 背景图像放在指定的背景色之上

  • background简写属性为background-color || background-image || background-repeat || background-attachment || background-position

浮动和定位

  • 浮动元素

  • 浮动定位规则

  • 浮动行为和浮动内容的的重叠

  • 清除(clear

  • 定位类型(position

  • 宽高限制(max/min-widthmax/min-height

  • 内容溢出(overflowclip

  • 元素可见性(visibility

  • 绝对定位、固定定位、相对定位

  • z-index

  • 浮动元素的外边距不会合并

  • 浮动的非替换元素需要指定width,否则宽度将趋于0

  • 浮动元素的包含块为距离最近的块级祖先元素

  • 行内框和浮动元素重叠时,边框和内容都在浮动内容之上;块级元素重叠时,内容在之上,边框和背景在之下显示

  • clear只应用于块级元素,清除区域不允许浮动元素进入

  • absolute元素会形成一个块级框

  • top等属性应用在absolute元素时,描述其距离外边距边界的距离

  • 可以通过top, left, right, bottom这样的定位信息确定绝对定位元素的内容区大小

  • clip通过rect(top,right,bottom,left)确定绝对定位元素的剪裁区域

  • 绝对定位元素的静态位置为其positionstatic时的位置

  • 对于绝对定位元素,垂直方向或水平方向设置auto可以垂直或水平居中

  • 对于相对定位,如遇到过度受限的情况,一个值会设置为另一个的相反数,保证自洽。

表布局

  • 表显示值(即相关display

  • 以行为主

  • 匿名表对象插入法则

  • 表标题

  • 表单元格(border-collapse, border-spacing, empty-cells

  • 单元格边框合并

  • 表大小(table-layout, 高度, 对齐)

  • CSS的列和列组只能接受borderbackgroundwidthvisibility四种属性

  • 单元格垂直对齐有4种可选值,top, bottom, middle, baseline

列表和生成内容

  • 列表类型、列表项图像、列表标志位置

  • 插入生成内容(contentattr(xxx), quote

  • 计数器(counter-reset, counter-increment

  • 列表样式可以简写为<list-style-type> | <list-style-image> | <list-style-position>

  • quote属性里指定开闭字符串后,可以通过content,结合:before:after伪类插入开闭quote的标记

用户界面样式

  • 系统字体(如caption等)

  • 系统颜色(已废弃)

  • 光标(cursor

  • 轮廓(outline

  • cursor有下面一些常见的值

    • pointer 用在超链接上
    • text 用来纯文本上
    • move 用来指示目标可以被拖动,相关的还有e-resize, ne-resize, se-resize等边缘的拖动标记
    • crosshair 用来指示可以选取范围,类似截屏的光标效果
    • wait 显示等待标记
    • progress 指示等待的状态,同时表示可以进行其他操作
    • help 显示帮助光标
    • url() 自定义光标图像,建议设置缺省值
  • outline的设置类似border,由outline-color, outline-style, outline-width组成

非屏幕媒体

  • 分页媒体(sizepage等)

  • 投影样式

  • 声音样式(speakstressrichness等)

  • 可以在<link>标签的media属性里指定媒体类型,或是在CSS文件中通过@media xxx {}的形式指定

最近离职后除了准备面试,多了许多时间对以往的前端个人项目进行改造。PhotoGallery就是其中一个个人很喜欢,但由于技术原因没做到完美的例子。最近准备抽出时间进行完善。

PhotoGallery是一个使用瀑布流展示电影海报以及花絮(当然都是个人比较心水的)的展示型页面。所有的电影图片也是从大一就开始收集的,一直囤积在人人上。页面的诸多功能是根据个人爱好设计的,如

  • 根据标签搜索
  • 相似图片
  • 基于tag的推荐等

总体来说,就是一个展示、介绍、推荐电影的地方。内容上还是很不错的。但是,去年寒假码代码时,前端技术还有待提高,很多地方写得并不严谨甚至比较丑陋。功能上也有些影响体验必须解决的痛点。大概有下面这些

  • 首屏渲染时间糟糕,这是因为图片过多(个人看的太多),又使用了Vue。同时Vue这种MVVM框架和精细化DOM操作一山不容二虎,因此,结合懒加载,效果依然不理想
  • 图片的时序排布并不自然,当时图省事,使用纯CSS方案实现瀑布流,牺牲了图片排序。图片只能从上到下再从左到右排序,和正常的阅读顺序并不一致。同时,最老的图片在最前,也不合理
  • 新增图片困难,这是由于github.io的纯静态的限制,当时采用了静态图片+meta存数据的方式来实现,后面看了电影再往里加图步骤繁琐,没有人性化的办法
  • 样式老气,细节粗糙
  • 本地调试困难,只使用了gulp来压缩js,css和json代码(代码少,不需要打包),不是全家桶脚手架,本地调试困难,且不能使用ES6语法
  • 代码语法和风格上不严谨,考虑结合在公司的规矩规范

针对上面大大小小几点,以及实际情况(比如只能使用github.io),考虑像下面这样优化

瀑布流布局实现方式待优化

放弃使用column-count的方案。原因有二:

  1. 排布顺序是从上到下,再从左到右,和日常经验相悖。类似地,使用flex的方案也不行
  2. 本身和懒加载的设计兼容性并不好,懒加载的新图片会导致整个页面的布局完全改变。类似地,使用grid的方案也不适合

因此考虑借鉴张鑫旭大神的方案,综合CSS和JS实现懒加载的滚动式瀑布流布局。

首先,根据屏幕宽度设置合理的列数,再逐列插入5张新图片,作为初始情况,同时,使用flex布局,设置flex-growjustify-content等属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const Wall = {
// ...
data() {
return {
columns: Math.floor(document.body.clientWidth / columnWidth),
lastFlag: Math.floor(document.body.clientWidth / columnWidth) * 5 - 1
}
},
// ...
computed: {
items() {
// ...
},
itemsForColumns() {
let ret = Array.apply(null, Array(this.columns)).map(() => []);
this.items.slice(0, this.lastFlag + 1).forEach((item, i) => {
ret[i % this.columns].push(item);
})
// 每列先只加载5个
return ret;
}
},
// ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
#photos {
display: flex;
flex-flow: row wrap;
align-items: flex-start;
justify-content: space-around;
width: 100%;
}
.wall-column {
display: inline-block;
width: 250px;
margin: 0 8px;
vertical-align: top;
}

之后,监听可能会改变布局的所有情况,在我这个场景下,大概有三种:

  • 滚动(scroll)事件
  • 缩放事件(resize)事件
  • 筛选图片,在改变筛选条件,会导致图片数目的变化

下面分情况解决之。

resize时

监听windowresize事件,当最后一列的位置变化时,意味着布局已经改变,需要触发重排。可以看到上面的itemForColumns中依赖columnslastFlag两个状态。这里我们利用MVVM框架的优势,维护这两个值,就可以让Vue帮我们完成重排这样的繁琐操作。如下,当columns改变时,才会触发重绘。

1
2
3
4
5
window.addEventListener("resize", e => {
this.columns = Math.floor(document.body.clientWidth / columnWidth);
// 已经展示过的图片就不要隐藏了
this.lastFlag = Math.max(this.columns * 5 - 1, this.lastFlag);
});

筛选图片时

同理,通过关键词筛选图片时,改变了传入Wall的prop factor。会同步更新依赖factoritem,触发重排。有一点有注意的是,**lastFlag需要重新开始累加**。

1
2
3
4
5
6
watch: {
items() {
// 设置了筛选条件后,lastFlag需要重新开始累加
this.lastFlag = Math.floor(document.body.clientWidth / columnWidth) * 5 - 1;
}
}

scroll时

页面滚动时,需要加入新的图片到column中,我们要做的只是更改lastFlag即可,Vue会帮我们自动完成依赖lastFlagitemForColumns更新。重点在,我们如何知道lastFlag应该增加到多少。

我们回看下itemForColumns的逻辑,可以发现新增的图片是循环摆放的。这里额外说一句,尽管新图片放在最短列是最合理的,但是工程上并不合算(一是Vue下做这么精细的DOM操作不合适,二是获知最短列意味着DOM操作已经发生,即会有频繁的回流和重绘,这会影响渲染时间)。我们循环考虑每一列的最底部位置,如果在视口内,将图片更新到该列,直到所有列底部都在视口外。直到图片加载完毕。

另外,在实践时还发现一个问题,handleScroll里更改了lastFlag后,Vue本身有batch的优化,会在microtask栈空后,才会进行耗时的DOM操作。循环添加图片时,需要通过setTimeout异步完成,避免误判,在一次递归中加载了所有图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
handleScroll(top) {
if (this.items.length <= this.lastFlag) {
return;
}
let delta = -1;
for (let i = 0; i < this.columns; i++) {
let col = document.getElementById(`wall-${i}`);
if (col && col.offsetTop + col.clientHeight < top + (window.innerHeight || document.documentElement.clientHeight)) {
delta = i;
}
}
if (!!++delta) {
this.lastFlag += delta;
// 直到所有列下沿都不在视口内,
// 同时,设置时延,保证DOM操作完成后再继续handleScroll
setTimeout(() => { this.handleScroll(top); }, 0);
}
}

新增图片困难

由于github.io是纯静态的页面,我并没有后台环境,这个痛点短期内只能缓解不能根除。不过后面考虑将所有图片迁移到图床上,毕竟把图片数据也存在github上感觉还是……有点怪怪的。日后新增图片应该还是通过上传图片,更新meta.json的形式完成。

目前已将所有图片迁移到图床上,图床选择上参考了知乎上的推荐,使用七牛云存储,在个人实名认证后,免费部分有每月10G国内和国外下载流量,100万次GET和PUT请求次数,和10G存储空间。同时,它还提供对图片的压缩等管理,尽量减少流量。

迁移之后,仓库体积大大减小。之后日常更新时,图片单独上传,根据外链固定前缀得到最终路径。

meta.json的更新上,考虑自己写一个工具,根据新看的电影生成新的content。

已完成自动生成meta.json小工具,原理很简单,就不再介绍了。

细节美化

点比较细碎。整体借鉴了material design的思想。

影片详细信息的遮罩

考虑使用100%的遮罩,同时禁止背景滚动的形式展示图片的详细信息。起初打算用js去实现,后来发现下面的两点使得方案并不简单

  • scroll事件不能被cancel,这意味着不能打断默认的滚动行为
  • 从Mouse,Keyboard,Touch相关触发scroll事件的事件劫持滚动行为倒是可以,不过要监听的事件太多

只好作罢,通过纯CSS的方式,弹出浮层时,为body指定noscroll的类名。让浮层的overflow属性为scroll即可,同时设置浮层position属性为fixed即可。

1
2
3
4
5
6
7
8
#display {
position: fixed;
top: 0;
left: 0;
/*...*/
z-index: 100;
overflow-y: scroll;
}

使用缓动函数改进回到开头

这里使用定义域和值域都是[0,1]easeInOutCubic函数。

1
const easeInOutCubic = t => (t<.5 ? 4*t*t*t : (t-1)*(2*t-2)*(2*t-2)+1);

有了缓动函数后,使用requestAnimationFrame即可高效率地绘制JS动画。这里封装了一个animate函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const animate = (obj, prop, end, time, ease) => {
if (!obj || !obj[prop] || time < 100) {
return;
}
let start = obj[prop],
k = end - start,
timer = null,
tick = timestamp => {
if (timer === null) {
timer = timestamp;
}
let progress = timestamp - timer;
obj[prop] = start + ease(progress / time) * k;
if (progress < time) {
requestAnimationFrame(tick);
}
};

requestAnimationFrame(tick);
}

后面直接使用animate(document.body, "scrollTop", 0, 1000, easeInOutCubic)就可以圆滑地上移了。

移动端的优化

  • 使用媒体查询,在屏幕宽度更改时,隐藏一些元素
  • 在UA为移动端设备时,给出提醒

loading样式

在改变筛选条件时,设置loading样式提升用户体验。通过积累onload的计数和初始加载图片值进行对比,在达到该值时清除遮罩。

1
2
3
4
5
6
7
8
9
10
// ...
loadedCount(newCount) {
if (newCount >= Math.min(this.lastFlag + 1, this.items.length)) {
console.log("全部加载完成");
this.isHidden = true;
} else {
// console.log("Loading...");
}
}
// ...

杂项

  • 导航条交互优化
  • 导航条部分设置阴影,更改部分字体颜色和背景色
  • 修改触发分类方式,由click改为mousemove
  • 修改tab的样式
  • 将vue和lodash的js文件下载到本地,避免CDN失效的问题(之前已经遇到过一次),增加可靠性
  • 搜索条件不区分大小写

本地调试困难

因为代码较少,也只有一个文件,用不着webpack这样的全套解决方案。小巧易用的gulp就够了。针对我们需要的ES6转码,替换minify方案,本地调试等需要,都有对应的gulp插件解决问题。

gulp-babel

使用babel来转码,gulp-babel依赖babel-core@6或以上版本,同时设置presetes2015或ES7相关版本时也需要下载对应module。

我只需要es2015即可。

1
npm install --save-dev babel-core gulp-babel babel-preset-es2015

React和ES7的各阶段可以像下面这样选择安装

1
2
3
4
5
$ npm install --save-dev babel-preset-react
$ npm install --save-dev babel-preset-stage-0
$ npm install --save-dev babel-preset-stage-1
$ npm install --save-dev babel-preset-stage-2
$ npm install --save-dev babel-preset-stage-3

其他工具

  • **gulp-uglify**,压缩代码
  • gulp-rename,为压缩出的js重命名
  • **gulp-webserver**,开启本地服务,方便本地调试

上面这些插件按照文档操作即可,坑比较少,使用webserver时的gulp.src()入参通常为./,指以当前目录为服务器根目录。

最后还需要加一个watch,方便在调试时的修改能同步转码压缩。像下面这样

1
2
3
4
5
gulp.task('watch', function() {
gulp.watch('assets/src/*.js', ['js']);
gulp.watch('assets/src/*.css', ['css']);
gulp.watch('assets/src/*.json', ['json']);
})

最后整个gulpfile.js是下面的样子

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
var gulp = require('gulp'),
babel = require('gulp-babel'),
uglify = require('gulp-uglify'),
rename = require('gulp-rename'),
cleanCSS = require('gulp-clean-css'),
jsonminify = require('gulp-jsonminify'),
webserver = require('gulp-webserver');

gulp.task('js', function () {
return gulp.src('assets/src/*.js')
.pipe(babel({
presets: ['es2015']
}))
.pipe(uglify())
.pipe(rename({ suffix: '.min' }))
.pipe(gulp.dest('dist'));
});

gulp.task('css', function () {
return gulp.src(['assets/src/*.css'])
.pipe(cleanCSS({compatibility: 'ie8'}))
.pipe(gulp.dest('dist'));
});

gulp.task('json', function () {
return gulp.src('assets/src/meta.json')
.pipe(jsonminify())
.pipe(gulp.dest('dist'))
});

gulp.task('webserver', function() {
gulp.src('./')
.pipe(webserver({
livereload: true,
directoryListing: true,
open: true
}));
});

gulp.task('watch', function() {
gulp.watch('assets/src/*.js', ['js']);
gulp.watch('assets/src/*.css', ['css']);
gulp.watch('assets/src/*.json', ['json']);
})

gulp.task('dev', ['default', 'webserver', 'watch']);
gulp.task("default", ['json', 'css', 'js']);

代码优化

从略。HTML和CSS部分参照以往写的建议即可。除此以外,优化了下面的部分:

  • 删除了为兼容移动端额外使用的touchend事件,增加viewport的meta标签,消除移动端chrome浏览器点击300ms延时情况
  • 由于引入了babel,删除了兼容ES6语法的自己写的polyfill部分
  • 使用fetch API请求json
  • 优化导航条点击事件处理相关的代码
  • 减少图片数目和json体积

参考

PWA

PWA(Progressive Web App)渐进增强的Web App。最早提出在2015年,它最初的设计理念是,保留Web的精髓,让Web逐渐演进成App,而非现在Hybrid App(即现在最常用的UIWebView/WebView+前端)形式。

  • 可安装性
  • 离线能力
  • 推送能力

在PWA的概念下,网页可以被添加到主屏同时支持全屏运行,在Service Worker帮助下可以离线运行,最后它仍是Web而并不用添加到App Store中。

说到Service Worker很多人可能会想到Web Worker的概念。这两个看起来是包含关系的概念实际上有区别。

  • Web Worker是JS多线程的一种实现方式,借助它可以让脚本在后台运行,worker对象和主线程通过message的方式交流,caniuse上的支持度为93%
  • Service Worker是浏览器的一个新特性,配合PWA的概念一起使用,是PWA网络请求的代理,结合缓存管理等方案,提供很好的离线体验,caniuse支持度仅有73%

一个介绍ppt上展示了具体的区别:

  • 和tab的关系,Web Worker是一tab对多Web Worker,Service Worker则是多对一
  • 生命周期,Web Worker和选项卡同生共死,Service Worker则是完全独立的
  • 擅长场景,Web Worker用在多线程协同,Service Worker则可以提供很好的离线体验
  • 为保证安全Service Worker要求scheme为https

Service Worker通过navigator.serviceWorker.register('path').then的方式注册,之后便能通过监听事件拿到所有scope里发生的请求,当然,可以在path后的第二个参数中显式地声明作用域(如{scope: '/js'})。Service Worker可以监听它声明周期中的各事件

  • Install 发生在第一次注册和sw.js(这里的文件名只是举个例子)改变时,通常在这个阶段设定SW的初始状态和准备好缓存。缓存可以借助caches API完成。
  • Fetch 发生在网络请求产生时,任何匹配了Request的网络请求都会被拦截,并返回缓存数据。只有找不到存在的缓存,才会产生一个请求
  • Activate 发生在SW更新或网页关掉再重新打开时,触发在install之后
  • Sync 发生在用户有网络时,用在用户进行依赖网络的操作时,会推迟到有网络时再执行。简单来说,所有的依赖网络的操作,都需要使用sync事件

除了Service Worker,Manifest也是很重要的一部分。它用来描述应用程序的各种信息。它包括下面一些成员

  • background-color 在css加载前用作应用背景颜色
  • name 应用名,short_name也是类似意思
  • description 应用描述
  • display 显示模式,有fullscreen, standalone, minimal-uibrowser几种可以选择
  • icons 应用图标,数组类型,每项包含src, typesizes几个属性
  • orientation 默认的屏幕朝向

这里有一个收集PWA酷站的地方。

Hybrid方案相关

离线包管理方案:

  1. 本地开发测试,提交特性分支到远端,
  2. 通过提MR的方式合并在当前迭代分支上,触发basement自动CI为zip格式,根据当前发包的状态,传递给NebulaMng管理
  3. NebulaMng基于zip生成版本号和配置文件,构建整个离线包,并推送给应用中心
  4. 应用中心负责向客户端推送更新
  5. 客户端根据策略拉取离线包、解压、渲染

离线包本地渲染方案:

  1. 加载公共资源包
  2. 判断本地是否已安装该离线包,若有,则加载到内存,否则触发离线包下载
  3. WebView加载离线包url链接,加载前检查内存中是否存在页面数据,若有,从内存中取出并渲染,否则fallback到线上cdn地址

离线包更新方案:

  1. 应用中心广播或服务端发sync消息触发
  2. 向wapcenter获取当前客户端下所有包信息
  3. 在本地没有当前版本且WiFi条件或auto_install为1时更新本地包

双向通信和JSBridge原理:

  • WebView在载入页面时,注入JSBridge脚本。通过调用JSBridge.call,触发调用参数的序列化,并调用console.log(h5container.message:xxx)window.prompt事件。WebView监听页面的console或prompt事件,解析传递的参数信息,然后通过NebulaService分发事件
  • Service、Session、Page实例化时,内部都有一个H5PluginManager成员,通过类似EventEmitter的形式存储着一个action -> plugin的map。每个plugin都有interceptEvent和handleEvent两个函数,处理事件的拦截和处理两个阶段
  • WebView通过loadUrl(“javascript:JsBridge.callback”)的形式输入结果并运行回调

实现上类似这样:

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
51
52
53
54
55
56
;//JsBridge
(function(window) {

var callbackList = {};

window.JsBridge = {
/* 参数说明
* @evt {string} 调用接口名称 (必须)
* @params {object} 配置参数 (可选)
* @callback {function} 回调函数 (可选)
*/
call: function(evt, params, callback) {
//第一个参数必须为string
if(typeof evt != 'string') return;

if(typeof params == 'function') {
callback = params;
params = null;
} else if(typeof params != 'object') {
params = null;
}

var callbackId = new Date().getTime() + '';
if (typeof callback == 'function') {
callbackList[callbackId] = callback;
}

var msg = {
callbackId: callbackId,
action: evt,
data: params || {}
};
prompt('JsBridgeCall', JSON.stringify(msg));
},
/* 参数说明
* @params {object} 返回的数据 (必须)
* 数据示例:{ callbackId: 'xxx', data: '' }
*/
callback: function(params) {
// params = JSON.parse(params);
var callbackId = params.callbackId,
data = params.data,
callbackHandler = callbackList[callbackId];
callbackHandler && callbackHandler.call(null, data);
delete callbackList[callbackId]; //删除回调
}
}
})(window)


;//JsBridgeReady
(function(document) {
var evt = document.createEvent('HTMLEvents');
evt.initEvent('JsBridgeReady', false, false);
document.dispatchEvent(evt)
})(document);

native和H5混合方案:

  • 在RootView中创建离线包View再异步添加进来
  • 通过JSBridge进行交互
  • 提前拦截touch事件,防止冲突

本文来自RxJS文档的Overview

RxJS是Reactive系列的JS版本。它有着下面一些概念。对它们有所理解将让你能得心应手地使用RxJS。

Observable

  • Observable类似函数定义(回调),Observer类似调用函数
  • Observable可以同步或是异步返回值
  • Observable在生命周期里可以返回多个值

Observable有创建(create)、订阅(subscribe)、执行(execute)、析构(dispose)四步。在订阅后,通过create方法创建的Observable体会立即得到执行(不论是同步或是异步内容),同时,传入create的回调函数中可以向Observer调用nexterrorcomplete方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var foo = Rx.Observable.create(function (observer) {
console.log('Hello');
observer.next(42);
observer.next(100);
observer.next(200);
setTimeout(() => {
observer.next(300); // happens asynchronously
}, 1000);
});

console.log('before');
foo.subscribe(function (x) {
console.log(x);
});
console.log('after');

Observer

Observers只是有next,error,complete三个回调的对象,这三个回调分别用来处理Observable传递的三种不同的状态

1
2
3
4
5
var observer = {
next: x => console.log('Observer got a next value: ' + x),
error: err => console.error('Observer got an error: ' + err),
complete: () => console.log('Observer got a complete notification'),
};

Subscription

Subscription表述了Observable的执行过程。但是主要提供unsubscribe()方法取消Observable执行

主要由Observablesubscribe方法创建,当然还有add()remove()方法组合subscription。

1
2
3
4
5
6
7
8
9
10
11
12
var observable1 = Rx.Observable.interval(400);
var observable2 = Rx.Observable.interval(300);

var subscription = observable1.subscribe(x => console.log('first: ' + x));
var childSubscription = observable2.subscribe(x => console.log('second: ' + x));

subscription.add(childSubscription);

setTimeout(() => {
// Unsubscribes BOTH subscription and childSubscription
subscription.unsubscribe();
}, 1000);

Subject

Subject是一个广播的Observable(类似EventEmitter),它既是Observable又是Observer,既有next方法,又有处理next的回调。

Observable本质的不同是,

  • Subject注册多个回调,Observable指定一个回调
  • 回调触发时机上,Subject通过特定时机触发(即Subject.next),Observable在回调定义后立即触发(即subscribe后)
1
2
3
4
5
6
7
8
9
10
11
var subject = new Rx.Subject();

subject.subscribe({
next: (v) => console.log('observerA: ' + v)
});
subject.subscribe({
next: (v) => console.log('observerB: ' + v)
});

subject.next(1);
subject.next(2);

Subject又可细分成BehaviorSubject, ReplaySubject, AsyncSubject

  • 使用refCount()替代connect()完成multicasted Observable的自动绑定

BehaviorSubject

存储了释放给消费者的最后一个值。在新消费者订阅时会自动下发。

1
var subject = new Rx.BehaviorSubject(0); // 0 is the initial value

ReplaySubject

存储之前释放给消费者的一组值。在新消费者订阅时会自动下发。

1
var subject = new Rx.ReplaySubject(100, 500 /* windowTime */);

第二个参数描述数据的过期时间

AsyncSubject

只存储最后一次释放的值,并complete状态后下发给消费者

Operator

RxJS的核心概念,读入一个Observable返回一个全新的Observable,是纯函数

Operator分为两类:

  • instance operator 用来对已有Operator链式调用进行改造,是纯函数,如.map()
  • static operator 用来从JS原始值中构造Observable,如.of().from()

RxJS提供的Operator非常多,以至于文档写了个小程序帮助你选择你想要的Operator。借助宝石图(marble diagram),可以更好理解各operator。

Scheduler

Scheduler允许你定义Observable发布消息给Observer的执行环境,具体来说如存储tasks,执行任务的时机和顺序,同步/异步等。

选择上,有Rx.Scheduler.queue(当前事件帧), Rx.Scheduler.asap(microtasks queue), Rx.Scheduler.async(setInterval)。static operator通常使用Scheduler,通过observeOnsubscribeOn两种方法指定。instance operator可以使用一个Scheduler。

现在可以跟着Tutorial使用起RxJS了。Enjoy~

此前一直未从0开始写过Redux的工程,近日想简单对比下ReduxMobx的各自特点,于是动手撸了TodoList,感受了它们的不同。对比上看

  • Redux可靠规整,有一整套最佳实践,写大型应用时能避免很多坑
  • MobX轻便锋利,概念不多上手容易,在中小型应用中开发效率更高

Redux

Redux吸收了Flux和Elm的设计特点,正如它在Three Principles中写到的那样,唯一可信数据源,状态数据只读,状态改变为纯函数,这三大特点最大可能提升了可预测性,减少了调试的难度。同时,在概念上也易于理解。不过,复杂的设定和较多的代码入侵使得个人项目使用时稍显笨重,团队项目使用在改动已有代码时又会有牵一发动全身的感觉。

在数据流上,Redux规定action描述state的改变情况,reducer根据action定义state如何更新。

  • action,由type和payload部分组成,描述发生了什么变化,如{ type: 'ADD_TODO', text: 'Eat pie.' }
  • reducer,接受state和action作为入参,返回一个全新的state,正如文档里所说Redux assumes you never mutate your data

在这种设计理念下,我们借助redux创建state,之后所有的状态更新,都通过state.dispatch提交action完成,再借由connect等工具同步更新组件的props实现数据绑定的效果。除了设计理念外,Redux一些工程实践上的设计也值得一提

和React的互动

Redux并不是和React绑定了,但确实经常和React同时出现。react-redux是用来和react绑定的库。在结合了Redux后,React会有一些最佳工程实践

  • 区分开绘制组件和容器组件,前者只负责将数据转化为标签,后者负责接入数据,完成逻辑,组织绘制组件,并向其传递用户交互触发dispatch的函数。这样的层级设计将会增加解耦,减少后期修改时的工作量
  • 在最外侧调用createStore生成store并传入<Provider>组件,内部的容器组件,通过connectmapStateToPropsmapDispatchToProps就能便利地和store进行沟通,就像下面这样
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const mapStateToProps = (state, ownProps) => {
return {
active: state.filter === ownProps.filter
}
}

const mapDispatchToProps = (dispatch, ownProps) => {
return {
handleClick: () => {
dispatch(setFilter(ownProps.filter))
}
}
}

const FilterLink = connect(
mapStateToProps,
mapDispatchToProps
)(Link)

Action Creator

使用函数创建标准化的action,而不要把action直接写在dispatch内。就像下面这样

1
2
3
4
5
6
7
function addTodo(text) {
return {
type: ADD_TODO,
text
}
}
dispatch(addTodo(text))

这么做在action发生更改时,只需要修改定义函数的位置即可,简单方便。可以发现上面的action creator中,完成的工作只是简单的组装type和函数的入参在同一个action object里。当这样的函数很多时,还可以用action creator creator来帮我们一行生成这些类似的action creator。

1
2
3
4
5
6
7
8
9
10
11
12
13
const createActionCreator = (type, ...argNames) => {
return (...args) => {
let action = { type }
argNames.forEach((arg, index) => {
action[arg] = args[index]
})
return action
}
}

const addTodo = createActionCreator(ADD_TODO, 'text')
const setFilter = createActionCreator(SET_FILTER, 'filter')
const toggleTodo = createActionCreator(TOGGLE_TODO, 'id', 'to')

Split Reducer

除了上面讲到的Action Creator外,split reducer页很常见,它的应用场景出现在当state比较复杂时,可以针对state的每个field单独写reducer,然后通过Redux的combineReducers组合起来。

就像TodoList中,筛选条件filter和待办事项todos可以拆出两个reducer去更改。

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
const todos = (state = [], action) => {
switch (action.type) {
case ADD_TODO:
return [
...state,
{
id: action.id,
text: action.text,
status: 0
}
]
case TOGGLE_TODO:
return state.map(todo =>
(todo.id === action.id) ? {...todo, status: action.to || +!todo.status} : todo
)
default:
return state
}
}

const filter = (state = 'SHOW_ALL', action) => {
switch (action.type) {
case SET_FILTER:
return action.filter
default:
return state
}
}

const todoApp = combineReducers({
todos,
filter
})

Middleware和Async Action

首先,我们要明确一点,Redux中只有dispatch能改变store,然而默认情况下,dispatch只接受action。因此,当我们想异步修改store时,异步的逻辑只能写在组件里(Vuex里则可以通过action异步提交commit)。设想一下,假如一个fetchAPI的逻辑在多处都用到时,只能在这些地方重复书写这些代码。好在,Redux提供了middleware的概念,和Express中的中间件类似,不同的是Redux中中间件处理的是用户提交的dispatch请求而已。

文档里对中间件的介绍非常到位,鉴于我们对express以及co中类似概念的了解,我们直接从第4步看起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function logger(store) {
let next = store.dispatch

// Previously:
// store.dispatch = function dispatchAndLog(action) {

return function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
}

function applyMiddlewareByMonkeypatching(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()

// Transform dispatch function with each middleware.
middlewares.forEach(middleware =>
store.dispatch = middleware(store)
)
}

可以看到,原理其实是类似的,关键点在于使用store的dispatch方法依次暂存上一个节点,这么做的好处是保证最终能抛出最后真正的dispatch,且能实现链式的效果。然而,使用dispatch显得还不自然,于是就有了下面的版本,每次将next主动传入,当让applyMiddleware里需要额外传入next。

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
const logger = store => next => action => {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}

const crashReporter = store => next => action => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}

// Warning: Naïve implementation!
// That's *not* Redux API.
function applyMiddleware(store, middlewares) {
middlewares = middlewares.slice()
middlewares.reverse()
let dispatch = store.dispatch
middlewares.forEach(middleware =>
dispatch = middleware(store)(dispatch)
)
return Object.assign({}, store, { dispatch })
}

借助中间件的帮助,我们可以在dispatch前完成我们想要的操作:打log,catch错误,甚至提前终止流程。而异步dispatch正是借助了thunk中间件的帮助。它的实现很简单。

1
2
3
4
const thunk = store => next => action =>
typeof action === 'function'
? action(store.dispatch, store.getState)
: next(action)

在我们通过applyMiddleware注入到store中后,就可以在dispatch中写入函数了!就像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function fetchPosts(subreddit) {
return dispatch => {
dispatch(requestPosts(subreddit))
return fetch(`https://www.reddit.com/r/${subreddit}.json`)
.then(response => response.json())
.then(json => dispatch(receivePosts(subreddit, json)))
}
}

function fetchPostsIfNeeded(subreddit) {
return (dispatch, getState) => {
if (shouldFetchPosts(getState(), subreddit)) {
return dispatch(fetchPosts(subreddit))
}
}
}

在使用时正常dispatch即可。

1
dispatch(fetchPostsIfNeeded(selectedSubreddit))

其他常用的store API

  • getState() 获取当前的state状态
  • subscribe(listener) 在每次更改state时触发
  • createStore() 根据输入的reducer,initState,middleware等生成store

脚手架

官网在介绍时列举了许多依赖库,却并没有给出示例的脚手架。没有舒服的脚手架,效率和工作热情都会受影响。这里介绍一种使用create-react-app快速搭建Redux脚手架的过程,下面的Mobx类似。

项目依赖大概有下面这些

但其实create-react-app可以帮你搭好其中包括react、react-dom、eslint、babel、webpack、postcss等绝大多数依赖环境,且完成配置。剩下的react-redux和react-router手动安装即可。

create-react-app类似于vue-cli,创建的默认配置不满意时,还可以npm run eject将默认配置撤销成用户配置,交给用户自己配置。

MobX

对比Redux,诞生于2015年3月的MobX在概念上吸收Vue,Knockout等MVVM框架要更多一些1,号称是TFRP(Transparent Functional Reactive Programming)。Transparent在依赖项的更新是隐式完成的,Functional在Computed中的使用,Reactive就不说了。MobX原名Mobservable,而后改名为MobX,官方并未说明如何发音,姑且读作moʊ-bex。和Redux对比来看,巧合的是MobX的设计理念也可以分为三部分:

  • 将所有的状态抽出来,用observable修饰
  • 描述出状态到视图的映射关系,这个过程因框架而异,但是一般React多一些,使用observer修饰
  • 在要修改状态的位置使用action包裹(强烈建议你这么做)

不过还有很核心的一点,文档里并没有提到

  • 把所有state修改的副作用放在autorun/reaction/when体内,在必要时在体内继续使用action

哇,是不是简洁了很多。你对observable对象做的所有修改(不论有没有用action包裹)都会自动反映在视图中,在项目结构上,你完全可以根据自己需要组织。不过,没有Redux里transaction的概念,MobX中对状态的修改在时间上都是不可回溯的。同时,没有中间件的概念,意味着在状态比较复杂时,可维护性就会下降。

整个MobX的关键API主要是由下面几部分组成的

  • observable 创建被依赖项,在设计中即state
  • computed 被依赖项的计算值,和Vue中的computed属性一致
  • action 动作,用来修改state,显式的使用可以使逻辑更清楚,当然不在action里修改observable也是允许的
  • observer和autorun/reaction/when,前者是derivation即根据state衍生出的结果,后者是reaction即state变化会触发的副作用(如IO等)

与React的结合

MobX和React相结合的方式就自由了很多。大体上使用components存储组件,stores描述状态。

stores描述状态时,

  • 通过@observable描述需要响应变化的状态变量,同时尽量将所有改变状态变量的操作封装成整个class的方法(并不强制),便于管理。
  • 通过@computed声明能够直接根据当前状态变量得到的衍生值。有意思的是,MobX只在observable变化时更新这个值,而不是在用户需要时去计算,从而节省了许多时间
  • 通过autorun()reaction()when()声明式地定义状态改变时的side effect,它们的执行结果都会返回一个dispose函数,在它们的生命周期结束后方便显式垃圾回收。它们三个都是除了用户操作外几乎唯一能进行副作用操作的地方,其中
    • autorun()在定义时就会被执行一次
    • reaction()仅在变化时执行,且对函数内部改变状态不敏感
    • when()则是在变化时执行一次即失效
  • 通过@action描述对状态做修改的行为,推荐使用,修饰在方法名前,开启strict模式后,则是强制要求使用

上面这些关键字都有修饰词(如:@action.bound)还有对应的ES5语法。(如:@observable key = value等同于extendObservable(this, { key: value }))。MobX并不要求使用单一的状态树,可以用多个文字组织你的状态。其中的一个store文件可能像下面这样:

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
class TodoList {
id = 0
@observable todos = []

@computed get activeTodoCount() {
return this.todos.filter(todo => todo.status === 0).length
}

saveToStorage() {
// 第一次不会触发
reaction(
() => this.toJS(),
todos => localStorage.setItem('todo-mobx', JSON.stringify({ todos }))
)
}

addTodo(title) {
this.id++;
this.todos.push(new TodoItem({id: this.id, title, status: 0}))
}

toggleAll(status) {
this.todos.forEach(todo => todo.status = +status)
}

delete(id) {
let todoId = this.todos.findIndex(todo => todo.id === id)
if (todoId !== -1) {
this.todos.splice(todoId, 1)
}
}

clearCompleted() {
this.todos = this.todos.filter(todo => todo.status === 0)
}

toJS() {
return this.todos.map(todo => todo.toJS())
}

static fromJS(arr) {
const todoStore = new TodoList();
todoStore.todos = arr.map(todo => TodoItem.from(todo))
return todoStore
}
}

components文件夹下做的事和使用其他框架其实差别不大。区别主要在引入observer后,用@observer装饰组件类。需要修改状态时,可以直接对props中传入的store进行
修改(不过还是建议使用store中定义好的方法修改),视图就会同步更新,副作用也会同步完成。一个使用了mobx-react的组件大概像下面这样

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
51
52
53
@observer
class TodoList extends Component {
render() {
const { todoStore, filterStore } = this.props
if (!todoStore.todos.length) {
return null
}
return (
<section>
<input
type="checkbox"
onChange={this.toggleAll}
checked={todoStore.activeTodoCount === 0}
/>
<ul>
{this.getVisibleTodos().map(todo => (
<TodoItem
key={todo.id}
todo={todo}
filterStore={filterStore}
handleDestroy={this.handleDestroy}
/>
))}
</ul>
</section>
)
}

getVisibleTodos() {
const { todoStore, filterStore } = this.props
return todoStore.todos.filter(todo => {
switch (filterStore.filter) {
case ACTIVE:
return todo.status === 0
case COMPLETED:
return todo.status === 1
case REMOVED:
return todo.status === 2
default:
return true
}
})
}

toggleAll = (e) => {
let checked = e.target.checked;
this.props.todoStore.toggleAll(checked !== 0)
}

handleDestroy = (id) => {
this.props.todoStore.delete(id)
}
}

除了直接使用store外,mobx-react还提供了将observable对象通过<Provider>inject2传入组件的方式。其他的用法可以参看文档,还有中文版翻译

脚手架

除了Redux里面提到的,因为MobX中用到了最新的decorator特性,.babelrc配置文件大概是下面这样

1
2
3
4
5
6
7
8
{
"presets": [
"react",
"es2015",
"stage-1"
],
"plugins": ["transform-decorators-legacy", "react-hot-loader/babel"]
}

package.json中需要额外引入”mobx”和”mobx-react”两个库(至少)。官方还提供了mobx-react-boilerplate,这些环境都已帮你配置好,按照README.md操作即可。另外,官方提供的awesome list是一个非常好学习mobx的地方

参考

  1. https://github.com/mobxjs/mobx#credits
  2. https://github.com/mobxjs/mobx-react#provider-and-inject
0%