下面的内容主要来自作者André Staltz的egghead.io

![cycle-flow](http://ow5o14n5d.bkt.clouddn.com/blogcycle-flow.svg)

设计

cycle.js设计上有三个特点:

  • 万物都是Stream(collections + 时间
  • Logic和Effect分离(借助maindrivers
  • app是纯数据流(data flow)

第一部分正如RxJS中介绍的一样,可以用Observable建模。

第二部分中,logic是数学相关的东西,是抽象的。effects则是影响实际世界的效应(如DOM、HTTP等),是实际的。将两者更好地分离开来是Cycle.js的设计初衷。而logic纯函数的特点优势也是很明显的(无副作用)。

上面的两部分在Cycle.js中,借助xstream, RxJS等Reactive Programming的库,以main和driver函数来实现。其中main实现逻辑、生产数据,driver订阅消费数据,交由cycle.js制造Effect。这里有一个浅显易懂的例子。

除了简单的DOM Effect外,还有HTTP请求等。交给不同的drivers完成就行了。这一点上和Elm设计很像。

对比流行的React、Angular、Vue,组件化的设计模式当然也涉及到。文档的Components部分讲得很清楚:

Any Cycle.js app can be reused as a component in a larger Cycle.js app.

即任何一个Cycle.js的应用都可以直接重用成更大应用的一个组件,无需额外的操作。原因很简单,任何一个logic都是在main函数中完成的,这也是开发者唯一需要做的事。而这个函数接收的sources以及返回给Cycle.js的sinks都是一个包含DOM、HTTP等stream的对象,且键值对都一样,可以看下面这张图更好理解:

![cycle.js component](https://cycle.js.org/img/dataflow-component.svg)

原理

完全可以在main中生产多种数据,作为对象返回,交给不同driver的得到不一样的effects。将main中数据交给driver的过程抽象在run函数中,完成数据生产和订阅的过程。这个过程并不复杂。下面是一个简陋的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
function run(mainFn, drivers) {
const sinks = mainFn();
Object.keys(drivers).forEach(key => {
if (sinks[key]) {
drivers[key](sinks[key]);
}
});
}

run(main, {
DOM: domDriver,
other: otherDriver
})

上面的实现手法只实现了单向的从逻辑到Effects的映射过程,要完成相反的从实际世界到逻辑的过程,需要读取外界的事件。要这么做,不仅main函数需要可以接受入参sources,生产Effects的drivers也要能够返回main需要的sources。类似地,sources的类型可以是DOM或者其他的合法input。同时,连接两者的run函数会遇到循环的问题,driver的入参和出参正好是main的出参和入参,这也是Cycle一词的来源。解决办法是先fake一个的初始数据流,得到Effects后,再用Effects初始化main,最后用main替换fake的数据流即可。

继续改造run函数,考虑到driver有多种类型,需要事先为所有driver都使用fakeSinks。在构造好drivers后,使用drivers的返回,构造main。最后用imitate替代掉fakeSinks即可。这就是Cycle.js核心部分run的设计思路,实际上,run部分的代码也只有一百余行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function run(mainFn, drivers) {
const fakeSinks = {};
Object.keys(drivers).forEach(key => {
fakeSinks[key] = xs.create();
})

const sources = {};
Object.keys(drivers).forEach(key => {
sources[key] = drivers[key](fakeSinks[key]);
});

const sinks = mainFn(sources);

Object.keys(sinks).forEach(key => {
fakeSinks[key].imitate(sinks[key];)
});
}

除了run,drivers也是Cycle.js设计的重要部分。它需要能够根据main逻辑的描述灵活地生成对应的Effects。如DOM,在main逻辑中声明所需的DOM结构,对应地,在domDriver中,根据结构生成实际的DOM元素(不论是使用createElement还是vDOM)。

不过仅仅在main逻辑中描述DOM结构是不够的,逻辑上还应该包括如何响应Effects的输入。类似地,这部分应该从driver中的hardcode抽离出来,由main声明,driver实现。类似下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function main(sources) {
const events$ = sources.DOM.selectEvents('span', 'click');
return {
DOM: events$.startWith(null).map(
xs.periodic(1000)
// ...
).flatten()
.map(i => ({
tagName: 'H1',
children: [
{
tagName: 'SPAN',
children:[`Seconds elapsed: ${i}`]
}
]
}))
// ...
}
}

上面的DOM结构可以进一步抽象函数,便于代码书写。另外,Cycle.js中使用makeDOMDriver的方式是为了显示声明DOM容器名,避免hardcode在driver中。

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
function h(tagName, children) {
return {
tagName,
children
}
}

function h1(children) {
return {
tagName: 'H1',
children
}
}

function span(children) {
return {
tagName: 'SPAN',
children
}
}

// ...
.map(
h1([
span([`Seconds elapsed: ${i}`])
])
)

使用

习惯了上面的思考方式后,可以考虑如何使用Cycle.js的问题了。通常情况下,一个空白的Cycle.js的脚手架像下面这样(使用UMD方案时):

1
2
3
4
5
6
7
8
9
10
11
12
const { makeDOMDriver } = CycleDOM;

function main(sources) {
// TODO
}

const drivers = {
DOM: makeDOMDriver('#app'),
// ...
}

Cycle.run(main, drivers);

结合HTML内容的声明和用户输入事件的读取,可以得到下面的结果:

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
const { div, label, input, hr, h1, makeDOMDriver } = CycleDOM;

function main(sources) {

// ''--------------->
// div-------------->

const input$ = sources.DOM.select('.name').events('change');
const name$ = input$.map(ev => ev.target.value).startWidth('');

return {
DOM: name$.map(name =>
div([
label(['Name: ']),
input('.name', {attrs: {type: 'text'}}),
hr(),
h1(`Hello ${name}!`)
])
)
};
}

const drivers = {
DOM: makeDOMDriver('#app'),
// ...
}

Cycle.run(main, drivers);

其中要格外注意的是,name$需要有startWith才能有流的起始数据,从而初始化真实DOM。

结合灵活的流操作符,如merge, fold等,可以实现更加复杂点的应用,如官网给出的计数器

除了DOMDriver,HTTPDriver也是很常用的一种Driver,可以借助它实现HTTP的request和response的响应。如官网给的样例

MVI

![MVI模型](http://ow5o14n5d.bkt.clouddn.com/cycle-mvi.svg)

MVI(Model-View-Intent)是Cycle.js提出的编程范式,可以将main的内容拆分成三部分,

  • 第一部分Intent将Effects传递过来的事件转换成Model逻辑可以接受的类型,
  • 第二部分Model实现具体逻辑,即state的改动
  • 最终由View部分将逻辑转换成DOMDriver可以接受的数据流传递到最后的vdom。

使用这种方式拆分后的代码实际上类似view(model(intent(sources.DOM)))。如文档中介绍的那样。

关于组件拆分和isolate,可以参看文档的Components部分。另外,在Cycle.js的状态管理工具cycle-onionify中也用到了isolate,使用isolate可以保证组件间的stream、状态相互独立。

xstream

万物皆Stream的概念是需要额外的库支持的。因此没有接触过RxJS的建议先学习一下这种思路。

Cycle.js允许使用RxJS等多种Reactive Programming库构造响应式的流结构,不过推荐针对Cycle.js定制的xstream。xstream学习成本简单,API仅有26个,此外文件体积小,速度适中。熟悉RxJS后,学习xstream就更简单了。

xstream的API分为FactoriesOperators。前者通过Producer或合并等方式生产新的Stream,后者是Stream的相关方法。

学习xstream了解4个概念就足够了(如果你已经熟悉RxJS的思想后),Stream, Listener, Producer, MemoryStream

  • Stream,类似EventEmitter和RxJS中的Subject,一个Stream可以注册多个Listener,Stream上有event出现时,所有Listener都会收到通知。除此之外,Stream可以通过operators生产新的Stream,如fold, map等。可以使用shamefullySend*手动触发event,但是应避免使用这种方式
  • Listener,和RxJS中的Observer类似,是有next, error, complete三种状态的对象,用来处理stream的三种event。通过addListenerremoveListener和Stream建立联系。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var listener = {
    next: (value) => {
    console.log('The Stream gave me a value: ', value);
    },
    error: (err) => {
    console.error('The Stream gave me an error: ', err);
    },
    complete: () => {
    console.log('The Stream told me it is done.');
    },
    }
  • Producer,生产Stream所需的event。xstream使用create(producer)等方法生产Stream。一个Producer只能绑定一个Stream。Producer本身拥有startstop便于在没有Listener监听时停止工作。
    1
    2
    3
    4
    5
    6
    7
    8
    9
     var producer = {
    start: function (listener) {
    this.id = setInterval(() => listener.next('yo'), 1000)
    },
    stop: function () {
    clearInterval(this.id)
    },
    id: 0,
    }
  • MemoryStream,和Stream不同的是会记录最后一次的event信息。类似RxJS里的BehaviorSubject。

生产Stream的函数有下面这些:

  • create(producer)createWithMemory(producer)
  • never() 生产不产生event的Stream
  • empty() 生产立即结束的Stream
  • error(error) 生产立即错误的Stream
  • from(input) 通过数组、Promise、Observable等生产Stream
  • of(a1,a2,a3) 生产根据输入产生的一系列event
  • fromArray(array), fromPromise(promise), fromObservable(observable)
  • periodic(period) 周期性产生递增的数
  • merge(s1, s2) 合并两个流
  • combine(s1, s2) 合并两个流中的值

Stream的相关方法有:

  • addListener(listener)removeListener(listener)
  • subscribe(listener)注册listener返回remove的函数
  • map(project)mapTo(projectedValue) 映射event中的值
  • filter(passes) 过滤
  • take(amount) 限制Stream的event数目
  • drop(amount) 忽略前amount次的event数目
  • last() 只释放最后一次event
  • startWith(initialValue) 以给定值开始
  • endWhen(other) 使用其他Stream决定是否完成当前Stream
  • fold(accumulate, seed) 以给定值开始累加
  • replaceError(replace) 取代一个流中的所有error
  • flatten() 将streams的Stream压缩为一个Stream,输出流中的数据只来自于当前Stream
  • compose(operator)
  • remember() 缓存最后一个值
  • debug(labelOrSpy) 不修改流,便于debug
  • imitate(target) 使用给定流替换原有Stream
  • shamefullySendNext/Error/Complete

另外一些实用的Stream相关方法,在extra部分中引入,包括如下

  • buffer(separator) 缓存部分内容一同输出,输出时机由输入的Stream决定
  • concat(s1, s2, ..., sn) 将Stream按照参数顺序从前到后连接起来
  • debounce(period)throttle(period) 防抖和节流
  • delay(period) 时延
  • dropRepeats(isEquals) 丢掉邻接的重复数据
  • dropUntil(other) 根据其他Stream决定该Stream的开始时机
  • flattenConcurrently() 类似flatten(),不过流中的数据根据时间merge,flattenSequentially()类似,将Stream先后连接
  • fromDiagram(diagram, options) 通过图表创建Stream
  • fromEvent(element, eventName, useCapture) 通过DOM事件创建Stream
  • pairwise() 和上一个值成对组成event的值
  • sampleCombine(streams) source流和其他发生时间最近的流event相组合
  • split(separator) 类似buffer,将stream拆分为释放streams的Stream
  • tween(config) 根据配置创建缓动函数,用于制作动画

参考

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%