再谈移动 Web 上的吸底布局

吸底布局也就是固定在页面底部,无论页面本身滚动到哪了。其最佳实现方式是使用 position:fixed,然而对于移动端来讲坑要多得多。

如果仅仅是吸顶的话,也就是对 fixed 的支持,也存在悲惨的过去,比如 Android 2.x 只有在特定的 viewport 设置下才会生效,iOS 在 8 以前要么不支持要么带有明显的漂移 bug。现在好多了,较新的版本都能很好地支持。

但对吸底来讲还不一样,就是因为 软键盘是从底部弹起的

软键盘弹起后,吸底的元素应该具有怎样的行为呢,这没有规范说明,于是 iOS 与 Android,Android 的不同版本之间,都各行其是。分歧在于,软键盘并非当前软件的一部分,如果它弹起了,页面的 window 大小到底变没变呢?

比较一致的是,两个平台上 resize 事件都没有被触发。但在部分 Android 机型上,如果页面的内容不能填充屏幕的高度,则页面确实会缩短,即 document.body.scrollHeight 会变小。其它 Android 和 iOS 则不会改变。

iOS 在软键盘弹起后非常一致地都将吸底的元素留在原处,并没上浮到键盘上面,而一些 Android 则会上浮。

两种策略很难说谁对谁错,iOS 可能有充足的理由不上浮:

  • window 大小并没有变;
  • 上浮可能导致内容区被遮挡

但很多人的第一印象是应该要上浮,就像部分 Android 做的那样。在 window 尺寸未变时,这反倒成了一种奇怪的行为,毕竟 fixed 不代表 always-visible

如果不依赖 fixed,使用 JavaScript 动态计算的话,需要知道软键盘的高度,在 iOS 上一些客户端的内嵌 Webview 中,这是可能的,因为键盘弹起后 window.innerHeight 这个值变化了,等于真正可视区域的高度。于是我们可以这样做:

1
2
3
4
5
6
7
8
9
.bottom{
height: 40px;
background-color: red;
width: 100%;
position: fixed;
bottom: 0;
left: 0;
transition: all .3s;
}
1
2
3
4
5
6
7
8
function fix() {
var t = Math.min(document.body.clientHeight, document.body.scrollHeight) - document.body.scrollTop - window.innerHeight;

$('.bottom').css({
'position': 'absolute',
'bottom': t
});
}

fixed 已经被 absolute 代替,动态计算 .bottom 元素相对于 body 的距离。注意当内容不足时,body 的高度要小于屏幕的高度。

效果看下面的影片:

不过这不但仅适用于 iOS,也仅在部分客户端上有效,Safari 是无效的。关于这种对吸顶元素的需求,应该还是避免。

Gitbook 插件的问题

Gitbook 是一个工具,可以将你的 Markdown 文档转换为 HTML、PDF 电子书,也是一个平台,你可以将电子书分享到上面去。不过严格来讲将 Markdown 发布为 HTML 没有任何技术含量,解析 Markdown 格式的工具不计其数。原始的 Markdown 语法非常简陋,在写书的时候,难免会用到一些图表、公式之类的,这种 Markdown 通过扩展也不在话下。

```[type]
[content]
```

type 处是这段代码的解析类型,常用的有各种语言语法以及 flowchatsequence 等扩展类型,这些扩展类型都需要引入运行时解析的js脚本。

Gitbook 通过注册插件,无需手动引入js:

1
2
3
4
// book.json
{
"plugins" : ["katex"]
}

然后运行 gitbook install 下载插件。

打开浏览器再浏览页面时,gitbook 已经将插件中声明的样式和脚本都注入到了页面当中。

但这有一个很大的问题,可以声明的脚本是个数组,你可以将任意数量的js注入到页面中,于是乎广大插件作者自然而然将依赖库放进去了,包括 jQuery,Raphael,Underscore 等等,你会看到:

1
2
3
4
5
<script src="..plugin-a/book/jquery.js"></script>
<script src="..plugin-a/book/plugin-a.js"></script>
<script src="..plugin-b/book/jquery.js"></script>
<script src="..plugin-b/book/plugin-b.js"></script>
...

是的,一个以上的插件将 jQuery 注入进去了,而 jQuery 是会向全局作用域暴露 $ 对象的,后面的会覆盖前面的,这导致很多插件都不能工作了。

目前官网还没有给出解决方案,对于不得不使用的插件,需要手动改造一番,尽量少向全局暴露对象。

同构场景下的复杂前端构建

提到 Web 前端工程化,构建,或者称为“编译”,是其中最重要的构成部分。由于出道比较晚,我在毕业后的第一家公司就职时就不得不掌握一定的构建知识和技能。当时我们使用的构建工具,使用 python 语言编写,主要能对 JavaScript 进行 AMD 规范的合并和压缩,对 CSS 进行合并压缩,以及增添各种时间戳。由于工具没有扩展功能,因此构建流程的固定的,如有必要,需要对工具进行升级,柔韧性并不好,好在当时的业务复杂度也并不高。

当时接手的一个腾讯的产品,其构建工具使用了包括 Shell、PHP、Ant 内的多种技术,非常繁杂,而且其重要构成部分 Ant 的任务扩展需要 Java 编写,成本并不低,也刚刚好也并没有额外的扩展需求,因此也就一直使用下去了。

不过伴随着 NodeJS 的发展,2013 年,grunt 已经开始出现,2014 年,gulp 也出现了。它们的适当使用都能更好地完成构建工作。

Webpack 虽然也在同时期发展,但直到近两年才被广泛应用。同时,在百度,FIS 也支撑着许多公司级的产品。

业务架构决定了选用何种构建工具,甚至是否需要像 Ember 一样自建工具。缺失场景冒然地比较它们是不合理不明智的,因为显然它们并非替代关系,只不过相互之间有些重合。

现在来看一个同构设计的复杂前端工程,它有以下特殊需求:

  • 一份使用 ES2015 语法编写的 JavaScript 既需要编译成 CommonJS 格式运行在 Server 端,又需要编译成 AMD 格式,压缩合并运行在浏览器端;
  • 为简便起见,使用 NPM 发布的前端 JavaScript Library 也允许编译到前端运行,包括所有依赖;
  • HTML 模板既可能作为 Server 端的运行时模板,也可能被浏览器加载作为前端模板;
  • 未被处理的文件需要原封不动拷贝至配置的对应目录中

先来看 FIS,这是一款有一定集成度的工具,即无需任何配置和插件都可以完成一定的构建任务。

来看其构建思路:

fis

FIS 定义了几个主要编译阶段,每个阶段可以插入更多的扩展。在编译期,FIS 会遍历所有项目下的文件,对不同的文件采取不同的转换策略,最终都会再次写在磁盘的目的目录中。这样的好处是不会遗漏任何文件,比如新增文件类型,即使不做任何处理,都会出现在目的目录中。缺点是,一个源文件就只能有一个输出文件。在同构项目中,常常一个文件有两个甚至更多的输出,做到这一点,FIS 比较困难,但能做到,只要多次读取一份源文件,但这在配置上不够直观。

再来看 Webpack,它天生支持编译 NPM module,对于 browserify 式的打包非常方便。但 Webpack 至少需要一个 entry,即入口,因为它需要知道从哪开始分析依赖。无论从哪里看,Webpack 对于那种单页应用都更友好一些,在复杂的工程中,页面不可能仅有一个,一个页面中的 JavaScript 运行入口也可能不止一个,同时,将一个源文件编译成多个也几乎无法支持。这使得 Webpack 的应用场景比较狭隘。

webpack

Grunt 与 Gulp 集成化最低,也最灵活,通过配合插件,可以完成任意任务。相比之下,Gulp 基于流的处理,会比 Grunt 基于文件的处理来说更易控制,处理也更快。两者都是基于文件选择器的,因此对于没有选择到的文件,必然会遗漏。同时,对于不同选择器选择到的相同文件,也会读取不止一次。

基于以上考虑,对于更复杂的要求更高的同构项目,单单是使用现有的构建工具,要么不能完成任务,要么实现起来非常 tricky。重新审视构建这件事,其实要做的事情非常纯粹——读取文件,将文件内容按照规则进行转换,再写到目的磁盘上。构建的重点是顺序,因为必然会有文件对文件的依赖,而这种依赖关系,要么像 FIS 一样预先定义,要么像 Webpack 一样自动分析,亦或像 Grunt/Gulp 一样自己定义。当然自己定义的灵活性最高,但基于有限文件选择器作为执行入口的方式,必然会无法处理未被选择的文件。

现在我们设想一种合适的工具,对以上提到的各种问题进行改进:

  1. 自定义处理流程和顺序;
  2. 允许处理遗留文件;
  3. 尽量减少源文件读取次数;
  4. 快速增量文件处理

一个典型的应用场景:

stream

基于以上需求,我做出的一个原型工具 PantoJS。它的实现原理非常简单:基于文件选择器定义处理流,但不立即运行,在每个流上挂载多个有序的转换器(Transformer),负责转换文件的内容。在最后统一进行构建时,每个流在每个转换器上都会缓存处理后的文件内容,只要文件没有改动,该转换器后面连接的多个分支转换器都会直接从该转换器上实时获取结果,而无需再次读取文件和转换。这也就支持了增量文件后项目的快速构建。另外,多个流在读取同一个文件时是有缓存的,即只会读取磁盘一次。最后,未被选择的文件则为遗留文件,按照预定义的遗留文件规则进行处理。

PantoJS 支持文件繁殖,即一个文件在转换器上能够生成多个文件。理论上,它能够支持任意需求的构建工作,但由于缓存和流的设计,仍有以下缺陷:

  • 流之间不能相互访问,而转换器允许访问原始文件,因此无能保证源文件被读取一次以内;
  • 内存消耗较高(可以适当做磁盘缓存)

不过我认为这种轻量级的设计才能承担越来越繁重的前端项目构建任务。通过不断扩展转换器,其潜力非常大。

从 margin-bottom 到 writing-mode

一个有趣的事实是,不论 margin 还是 padding,都可以设置百分比值:

1
2
3
4
.content {
margin: 5%;
padding: 10%;
}

直观上来讲,margin-leftmargin-rightpadding-leftpadding-right 是相对于包含框的宽度,而 margin-topmargin-bottompadding-toppadding-bottom 相对于包含框的高度。然而根据 CSS 规范,这8个值一般都是相对于包含框的宽度,究其原因还是排版的习惯需要所致。

世界上大部分的现代语言书写习惯,或者从左到右,或者从右到左,都是横排,因此一般宽度受限,向下无限延伸,高度不定,因此相对于高度计算边距值意义不大。

古代汉语文字有着垂直从右到左书写的习惯,比如千家万户门前的对联,比如书法:

书法

为了实现这种排版,我们通过设置 writing-modedirection 实现:

1
2
3
4
.content {
writing-mode: vertical-lr;
direction: rtl;
}

这种设置除了影响文字的排列外,也相应影响了 paddingmargin 在百分比取值下的参照物:即相对于包含框的高度而非宽度。

值得一提的是,另一个常见 CSS 特性的行为也会因此被改变:外边距合并。通常情况下,发生外边距合并的是两个相邻块级元素的垂直外边距,在垂直文字布局情形下,发生合并的是水平外边距。

CSS 排版布局中在具有不对称行为时,一定也存在着某种默认的行为设置。比如使用 margin:auto 能使水平居中而非垂直居中。

有序 BigPipe

纪念此猫

概念

BigPipe 的原理是使用 HTTP/1.1chunk 能力分片多线程加载页面的不同部分,以降低白屏/首屏时间。由于 HTML 文档(document)是一篇纯文本,文本的内容直接决定了最终页面的展现形态和功能,因此虽然服务端可以采用多线程加载数据和输出页面片段,但在浏览器端这些片段必须保证是有序排列。于是,JavaScript 脚本担负起了这个职责,它将 HTML 片段字符串渲染成为 DOM。

在移动端,JavaScript 脚本渲染页面存在可观的性能问题,频繁的 reflow/repaint将会很大程度影响页面首次展示的视觉体验。既然 JavaScript 脚本在前端不适合做渲染者的角色,那么我们考虑将渲染放在服务端,这也就是传统的 C/S 模式。但是因此前面提到的顺序问题,服务器必须保证整个页面文档是按照顺序输出的,加上 chunk 能力,我们得到的是一种退化的 BigPipe:可以降低首屏时间,但会限制整个页面的最终输出时间,我们叫它 Ordered BigPipe

应用 Ordered BigPipe 的前提是你的页面可以分解为相互独立的,自上而下的页面片,每个页面片在 chunk 通道中依次输出:

1
2
3
4
S ---------------
/ \ \ \ \
/ \ \ \ \
C ---------------

使用 PHP 语言可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Demo</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="stylesheet" href="style.css">
</head>
<body>
<?php
flush();
?>
<header></header>
<?php
flush();
?>
<article></article>
<?php
flush();
?>
<footer></footer>
</body>
<script src="script.js"></script>
</html>

以上页面分为四段进行输出。由于 Server 不必查询到页面所需要的全部数据,因此可以大大提高系统的响应速度,进而有效降低白屏与首屏时间。

实现

Server

上面提到,Server 需要分段获取数据,那么如何定义每段的数据需求呢?两种方式:

  1. 前后端约定一个页面划分哪几段,再规定每段应该有什么样的数据;
  2. 由前端自由划分段落,主动向 Server 索取数据

显然,第二种方式,虽然实现更复杂,然而更灵活,解耦更好。

首先定义模型 DataProvider,它的定义是:一组可查询的相关联数据的最小单位。例如查询用户信息和查询用户最近发言两个 DataProvider

1
2
3
4
5
user_id:
name:
address:
mobile:
gender:
1
2
user_id:
user_recent_posts:

既不能单独查询 user_profile.address,也不能同时查询 user_profileuser_recent_posts

合理地规划 DataProvider 有助于提升效能。

接下来,在段中声明它所需要的所有 DataProvider 列表:

1
2
3
data-providers:
- user_profile
- user_recent_posts

这样,Server 端可以收集所有的段以及它们依赖的 DataProvider

1
2
3
4
5
segment:
user_profile
segment:
user_profile
user_recent_posts

按照先后顺序,依次查询每个 DataProvider 的数据,一旦满足最上面段所需要的数据,则进行一次页面输出。以下面的依赖为例:

1
2
3
4
5
6
7
8
9
sg1:
dp1
dp2
sg2:
dp1
dp3
sg3:
dp2
dp4

那么 DataProvider 按顺序排列后为:

dp1->dp2->dp3->dp4

段组成的队列为:

1
2
3
4
|sg1| 
|sg2|
|sg3|
-----

首先填充 dp1 的数据,发现 sg1 的需求并不能满足,接着填充 dp2 的数据,这时 sg1 得到满足,出队,进行页面渲染输出,但 sg2 还不能。

紧接着填充 dp3 的数据,sg2 进行渲染输出。同样的道理,在填充 dp4 之后,sg3 也可以开始渲染了,直到队列中为空。

前端

Ordered BigPipe 的前提是服务端渲染。

“段”实际上是一种前端的模型,它代表页面可垂直划分的片段,不支持嵌套,因为嵌套会带来潜在的标签不闭合风险。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<html>-----------------------------------
<head> \
<meta> \
<title></title> Segment
<link rel="stylesheet" href=""> /
</head> /
<body>-------------------------------
<article>-------------------------------
\
Segment
/
</article>------------------------------
<script src=""></script>----------------
\
</body> Segment
/
</html>-------------------------------------

总结

Ordered BigPipe 仅适用于垂直无嵌套排列的页面。

Chrome bug 之 JSON 序列化 PerformanceResourceTiming 对象

利用 nightmare 做下线性能对比分析中,少不了要获取 PerformanceResourceTiming 数据:

1
performance.getEntriesByType('resource');

但运行 nightmare 的 NodeJS 环境与运行 getEntriesByType 的 Browser 环境之间只能传输简单数据类型如 string、boolean、number,故下面的写法不可行:

1
let resources = yield nightmare.evaluate(() => performance.getEntriesByType('resource'));

当不得不写成这样时:

1
let resources = yield nightmare.evaluate(() => JSON.stringify(performance.getEntriesByType('resource')));

你会发现返回值是这样的:

1
2
3
4
5
6
7
8
[
{
"name": "https://...",
"entryType": "resource",
"startTime": 1059.82,
"duration": 413.96500000000015
}
]

显然这比我们见过的 PerformanceResourceTiming 对象少了好多字段。

转向 Chrome,执行类似的操作,发现这并非 nightmare 的问题,而是 blink 内核的bug。Safari 由于不支持 getEntriesByType 而无法测试,Firefox 则输出正常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
{
"name": "https://...",
"entryType": "resource",
"startTime": 4326.712201,
"duration": 1264.9166809999997,
"initiatorType": "xmlhttprequest",
"redirectStart": 0,
"redirectEnd": 0,
"fetchStart": 4326.712201,
"domainLookupStart": 0,
"domainLookupEnd": 0,
"connectStart": 0,
"connectEnd": 0,
"secureConnectionStart": 0,
"requestStart": 0,
"responseStart": 0,
"responseEnd": 5591.628882
}
]

造成该现象的原因是,序列化 JSON 时,字段取的是 PerformanceEntry 而非其子类 PerformanceResourceTiming

目前,twitter 员工提出的该 bug 与我先前提出的雷同,代码上已经被修复,等待 Chrome 50 的发版。

滚动区内消失的右边距

观察这个页面,在可滚动区域内部,为 .content 元素定义的右边距消失了。

Missing Right Margin

StackOverflow 上有关于这个现象的详细解释,简单来讲这就是 CSS 这么要求的。

但是回过来想一想 CSS 为什么这么要求?这还要从边距和滚动条的意义说起。边距,顾名思义,就是定义本元素与四周元素的距离的,在元素排列的方向上(一般是ltr),如果后面没有元素,那么边距也就没有意义了。滚动条,其目的是提供一种能显示后代元素完整内容的方式,既然是内容,那么其右侧边距的渲染便没有了意义。以上两个概念综合到一起,当子元素内容大于父元素激活滚动条时,右侧的边距便被当做0处理。在 rtl 的排列中,相应的,是左边距。

其实,如果给容器 .container 定义内边距,会发现其右侧内边距也会“消失”,这与 .content 的右外边距“消失”是一个道理。

这个现象会直接影响容器 .containerscrollWidth 属性以及滚动条最大滚动量。

另外先入为主应当显示右边距的想法应该是与 iframe 混淆了。iframe中的页面元素可以有外边距,这属于另一个文档(document)了,与本文情形不同。

CSSOM View Module 中的尺寸与位置属性

CSSOMCSS Object Model,即 CSS对象模型。CSSOM 是 JavaScript 操纵 CSS 的一系列 API 集合,它属是 DOM 和 HTML API 的附属。

其中视图模型(View Model)中定义了一系列接口,包括多个关于窗体、文档和元素的位置尺寸信息,特别容易混淆。

Window 接口

innerWidth/innerHeight

浏览器窗口可见区的高宽,包括滚动条。

outerWidth/outerHeight

浏览器窗口的外边沿宽高。

scrollX/scrollY

文档水平/垂直滚动量。

pageXOffset/pageYOffset

同上。

screenX/screenY

浏览器左上角距离屏幕左上角的距离。

Screen 接口

availWidth/availHeight

屏幕可用区域的尺寸。

width/height

屏幕整体尺寸。

Element 接口

clientWidth/clientHeight

元素本身尺寸,包括 padding,但不包括 border、margin 和 scroll。

因此一个设置了 width:100px 的元素出现宽度为 15px 的覆盖式滚动条后,它的 clientWidth 为 85px。

一些浏览器的滚动条是半透明的,并允许覆盖页面元素,这种情形下,clientWidth 为 100px。

scrollWidth/scrollHeight

元素的内容区域尺寸,如果有滚动条,则包括隐藏的部分。

这一对值与该元素的后代元素相关。如果后代元素尺寸超过了该元素的大小,不管后代元素是否被隐藏(overflow 或 visibility),都会计算在内。

clientTop/clientLeft

元素内容与整个元素的位置偏移,理论上包括边框宽度与滚动条,由于一般滚动条都位于右下侧,因此这一对值基本上就是左侧和顶部边框的值。

scrollTop/scrollLeft

滚动条滚动的位移。

两者均可计算最大值:

maxScrollTop = scrollHeight - clientHeight
maxScrollLeft = scrollWidth - clientWidth

计算滚动条宽度的方法,对于浏览器最外层滚动条:

window.innerWidth - docment.documentElement.clientWidth

对于普通元素:

offsetWidth - clientWidth - leftBorderWidth - rightBorderWidth

HTMLElement 接口

offsetWidth/offsetHeight

元素本身尺寸,包括 padding、border,但不包括 margin 和 scroll。

即:

offsetWidth = leftBorderWidth + clientWidth + rightBorderWidth
offsetHeight = leftBorderWidth + clientHeight + rightBorderWidth

offsetTop/offsetLeft

元素外边沿(border)与最近一个定位祖先元素内容区(包括 padding 但不包括 border)的距离。


图例

Legend

如上图,假设黑色框代表浏览器,DOM结构为:

1
2
3
4
5
6
<div class="container">
<div class="content">
<article>
</article>
</div>
</div>

样式设定为:

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
html
body {
margin: 0;
padding: 0;
}
.container {
width: 300px;
height: 300px;
overflow: scroll;
border: 10px solid #ddd;
margin: 80px;
position: relative;
padding: 88px;
}
.content {
width: 500px;
height: 500px;
background: #ccc;
padding: 60px;
margin: 75px;
border: 20px solid #785645
}
article {
width: 1000px;
background: #ff0;
}

我们来看一下 .container.content 这两个元素的各种尺寸与位置值。

.content
属性 说明
scrollWidth 1060px 后代元素 article 宽度 1000px 超过了 .content 宽 500px,因此实际上 .content 的内容宽度为 1000px(article) + 60px(leftPadding);如果 article 没有溢出,则为 500px(.content) + 60px(leftPadding) + 60px(rightPadding)
scrollHeight 620px 后代元素未溢出,因此 .content 内容高度为 500px(height) + 60px(paddingTop) + 60px(paddingBottom)
clientWidth 620px 500px(width) + 60px(leftPadding) + 60px(rightPadding)
clientHeight 620px 500px(width) + 60px(leftPadding) + 60px(rightPadding)
offsetWidth 660px clientWidth + 20px(leftBorderWidth) + 20px(rightBorderWidth)
offsetHeight 660px clientHeight + 20px(leftBorderWidth) + 20px(rightBorderWidth)
clientTop 20px 上边框宽度
clientLeft 20px 左边框宽度
offsetTop 163px 88px(.container paddingTop) + 75px(.content marginTop)
offsetLeft 163px 88px(.container paddingleft) + 75px(.content marginleft)
scrollTop 0 没有滚动条
scrollLeft 0 没有滚动条
.container
属性 说明
scrollWidth 1243px 1060px(.content scrollWidth) + 20px(.content leftBorderWidth) + 75px(.content leftMargin) + 88px(.container leftPadding),注意即使 .content 的后代 article 内容没有溢出,.container 的 scrollWidth 也是 660px(.content scrollWidth) + 75px(.content leftMargin) + 88px(.container leftPadding),右侧的内外边距并不增加,见这里
scrollHeight 986px 620px(.content scrollWidth) + 20px(.content topBorderWidth) + 20px(.content bottomBorderWidth) + 75px(.content topMargin) + 88px(.container leftPadding)
clientWidth 476px-滚动条宽度 内容区宽度 + padding,去除滚动条
clientHeight 476px-滚动条宽度 内容区高度 + padding,去除滚动条
offsetWidth 496px 300px + 88px × 2 + 20px × 2
offsetHeight 496px 300px + 88px × 2 + 20px × 2
clientTop 10px 上边框宽度
clientLeft 10px 左边框宽度
offsetTop 80px 最近一个定位元素是body,取 topMargin
offsetLeft 80px 最近一个定位元素是body,取 leftMargin
scrollTop 不定 取决于滚动位置,最大值为 scrollHeight - clientHeight
scrollLeft 不定 取决于滚动位置,最大值为 scrollWidth - clientWidth

详见这里

在 CSSOM 草案出台之前,许多浏览器就已经支持其中相当一部分,CSSOM 的目的是规范这些取值,可以看到其中有些量的意义是相同的,比如 scrollX/pageXOffset、scrollY/pageYOffset。另外:

window.scrollX = document.body.scrollLeft = window.pageXOffset
window.scrollY = document.body.scrollTop = window.pageYOffset

如何定义 redux 的 action

虽然 redux 的模型非常简单,但如何对其理解不深,在实际的业务研发中很容易迷失,比如会纠结该如何定义枚举的 Action Type

传统的不加任何设计思想的应用经常是这样的:

1
2
3
4
5
6
7
8
9
10
11
var App = {
$container: $('#container').html(),
cache: [],
isLoading: false,
init: function (opt) {
this.opt = opt;
},
render: function () {
this.$container.html({data: this.cache.slice(0, 5), isLoading: isLoading}});
}
};

也就是说,我们经常会把应用的状态属性和显示的数据混淆在一起,数据只有一份,但视图在不同的位置可能是各种各样的,这需要我们维护多份视图数据。

在 Redux 中,我们只需要在 store(state) 中定义最原始的状态数据即可,_对该数据任何一部分的改变即可定义为一个Action__,而将状态数据转化成视图数据的逻辑可以定义在 mapStateToProps 函数中,这是一个 redux-react 中的函数。总之,视图数据应该和状态数据保持分离,并维持状态数据的简洁性,从而使得 Action 的定义更清晰。

Redux 的黑魔法

相信很多人接触 Redux 时都会被它奇怪的 API 搞得云里雾里。这里不再冗述 Flux 架构的思想,实现 Flux 的工具有很多,它们只是在实现这种编程模式,并不会有太复杂的逻辑。事实也是这样,Redux 的 API 非常少,但并不一定容易理解。

createStore

createStore 没什么好说的,创建 Store,但其参数值得一提——一个称为 reducer 的函数。reducer 函数接受两个参数,stateaction,依据 action 对 state 进行复制更新并返回:

1
2
3
4
5
6
7
8
9
10
const store = createStore((state = {counter: 0}, action) => {
switch(action.type) {
case 'add':
return Object.assign({}, state, {counter: state.counter + 1});
case 'minus':
return Object.assign({}, state, {counter: state.counter - 1});
default:
return state;
}
});

combineReducers

辅助函数,分解 reducer 之用。如:

1
2
3
4
5
6
7
8
9
// 分解前
const reducer = (state = {counter: 0, status: 'idle'}, action) => {

};
createStore(reducer);
// 分解后
const counter = (state = 0, action) => {};
const status = (state = 'idle', action) => {};
createStore(combineReducers({counter, status}));

可见 combineReducers 只不过把 state 的各个属性分解开来进行处理,再把处理后的数据合并起来。

applyMiddleware

一种 store enhancer,使用 compose 来强化 createStore 的能力。这一个理解起来非常绕的函数。它允许对 createStore 进行多层包装,并修改了返回的 store 对象,可以在 dispatch 操作前后执行其它逻辑,有点类似 AOP 的感觉。事实上只要包装 dispatch 方法就能实现这些功能,也印证了 Redux文档 的这句:

Middleware only wraps the store’s dispatch function. Technically, anything a middleware can do, you can do manually by wrapping every dispatch call, but it’s easier to manage this in a single place and define action transformations on the scale of the whole project.

bindActionCreators

辅助函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function addTodoActionCreator(text){
return {
type: "add",
text: text
};
}

// 调用 bindActionCreators(addTodoActionCreator, dispatch) 后

function addTodoAction(text){
dispatch({
type: "add",
text: text
});
}
// 这样可以直接调用addTodoAction来派发 Action。

我们也可以实现自己的 bindActionCreators

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function bindActionCreators (actionCreators, dispatch) {
if('function' === typeof actionCreators) {
return function () {
dispatch(actionCreators.apply(arguments));
};
} else {
let ret = {};
for (let e in actionCreators) {
if('function' === typeof actionCreators[e]) {
ret[e] = bindActionCreators(actionCreators[e], dispatch);
}
}
return ret;
}
}

compose

辅助函数,用以解构深度嵌套函数,体现了柯里化的编程模式,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 function $1 (func) {
return function () {
return func.apply(null, arguments)+ '1'
};
}

function $2 (func) {
return function () {
return func.apply(null, arguments) + '2';
};
}

function $3 (num) {
return num;
}

$1($2($3))(3);// 321

(compose($1, $2, $3))(3)// 321

你也可以实现自己的 compose

1
2
3
4
5
6
7
8
9
10
11
12
13
function compose () {
const args = Array.prototype.slice.call(arguments).reverse();
let tmp = args[0];
if (!tmp) {
return function (a) {
return a;
}
}
for( let i = 1; i < args.length; ++i){
tmp = (args[i])(tmp);
}
return tmp;
}

除了 Store 对象的方法外,这五个 Redux 的核心函数中有三个为辅助函数,执行各种“魔法”操作,如果没有这些预定义的函数,可能会增加冗余代码量,但绝不会影响你实现 Flux。

除了不十分常用并且仍可自实现的 applyMiddleware 之外,只有 createStore 为刚需函数,因此可见调用 Redux 并不复杂。这可能要联系到 Flux 架构这种单向的数据流转方式,对于解耦业务逻辑十分简单并且凑效。