使用rust & webAssembly开发导出excel文件功能后的感想

前言

已经超过半年没写过文章了,原因除了肯定会有的懒以外,主要还是因为,
其一,根据过去的经验,写一篇文章要花比较长的时间,要先透彻地明白自己所讲的问题;
其二,部分学习和总结是已经写成代码了,再写成文字似乎就重复了;
其三,只想写一些自觉有新意的内容。

说回这次的要实现的功能。在以前公司做导出excel文件是放在后端做的,所以当数据量和使用人数过多的时候就出现了接口超时的问题。为了减轻后端的压力,所以这次打算后端提供必要的数据,excel文件由前端生成。

说到文件生成,我第一时间就想到了web workerwebAssembly的使用,加上之前学了下rust,正好可以学以致用。

这个功能做完以后,粗略地测试了下,导出一个263KB文件,webAssemblyjs的实现的导出速度。
webAssembly|js
———–|—
62ms| 52ms

所以从效果上来说,webAssembly实现完全是白做了。查了些资料,我觉得原因主要是:

  1. 生成Excel的计算量不大,数据的传送反而占了比较多的时间
  2. 数据要传给webAssembly,需要先转成JSON,再encodeUint8Array,这个是与JS的实现相比额外的消耗。

项目介绍

项目地址

这个项目是一个fork项目,除去原有的核心的文件构建逻辑,我做的改动主要有:

  1. verticalAlign的支持
  2. 支持数字类型的单元格数据
  3. 添加作为回退方案的js实现
  • 改动1其实只是按照原来的做法,增加对verticalAlign的处理。

  • 改动2的问题是对既可能是字符串又可能是数字的数值处理,处理的方法是改成枚举类型,然后还要增加两行宏#[derive(Deserialize)]#[serde(untagged)],前者是支持serde库进行反序列化,后者是让serde自动判断反序列转化的类型。详细可以在这里查阅

    1
    2
    3
    4
    5
    6
    #[derive(Deserialize)]
    #[serde(untagged)]
    pub enum Value {
    Number(f64),
    String(String)
    }
  • 改动3是因为webAssembly只有在17年后的浏览器有支持,所以需要js方案作兼容,此方案用到了exceljs这个库,由于是运行在worker环境,不可通过script标签加载,另一方面第三方库是希望作为外部引用的,所以需要给打包生成的worker文件头部增加importScript方法。经过一些资料的查找,只需要设置rollupoutput.banner即可

对WebAssembly的认识

下面讲述自己阅读WebAssembly相关的材料后,梳理出的对WebAssembly的认识。

  1. WebAssembly从名字中能看出其两个性质,第一是它有着与汇编语言相似的格式,其指令易于机器执行;第二是它是被设计为面向网络应用的,包括客户端和服务端。

    • 它有两种格式:

      1. .wat(WebAssembly text format file),因为它是文本格式,所以我们可以阅读和编辑,但它不能直接被执行,需要转换为.wasm文件。
      2. .wasm,真正的WebAssembly程序文件,由二进制编码。由于WebAssembly是类汇编的初级语言,所以它可以被如C++rust等高级语言作为编译对象而生成出来,从这个意义上,它有着不区分开发语言,通用跨平台的特点,就像我们系统中的可执行文件一样。
  2. WebAssembly的开发方式:

    1. 直接编写.wat文件。
    2. 编写高级语言后进行编译。
    • 由于.wat的数据类型目前只有i32 | i64 | f32 | f644种,虽然可以定义函数,但运算操作是基于栈式虚拟机,比较初级,相比高级语言,编写代码量过大并且与实际业务逻辑的编写习惯相差甚远,所以生产开发是选择方式2

    • 不过为了理解栈式虚拟机和WebAssembly的执行机制,下面通过某个网络安全题目提供的.wasm转译成的.wat内容进行简单说明。

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
(module
// wat中 ;; 相当于 js的 //注释, (;;)相当于 /**/注释
// 定义 (func (param i32 i32) (result i32)) 参数为2个i32类型的值,返回值为i32类型的函数为按顺序为type 0,即第一个type
(type (;0;) (func (param i32 i32) (result i32)))
// 从JS运行时中import Math.min 和 Math.max,分别按顺序为func 0 和 func1 并且指明它们都为type 0,
// 这里import为使用WebAssembly.instantiateStreaming(response, imports)或WebAssembly.instantiate(buffer, imports)实例化WebAssembly时的第二个参数。
// 此例中可以为{ Math: { min: Math.min, max: Math.max } },同时也可以看出在`.wat`中是以空格分隔imports中的引用层级的
(import "Math" "min" (func (;0;) (type 0)))
(import "Math" "max" (func (;1;) (type 0)))
// 定义 func 2 并指明其为 type 0
(func (;2;) (type 0) (param i32 i32) (result i32)
// 声明6个局部变量
(local i32 i32 i32 i32 i32 i32)
// 从局部变量中取索引为0的变量值,放到栈顶,此时栈表示为 [var0],注意局部变量是根据声明顺序分配索引值的,除了手动定义的6个局部变量,2个参数亦为局部变量,分别为0,1,而手动定义的局部变量则是索引2 - 7
local.get 0
// 栈顶出栈,并把值赋给var2,此时栈为[]
local.set 2
// 栈为[var1]
local.get 1
// 表示将i32类型的数值1进栈,所以此时栈为[var1, 1]
i32.const 1
// sub表示substract,即相减,出栈两个值作为操作数,并把结果放到栈顶,此时为[var1 - 1]
i32.sub
// tee除了有set的作用,还有把值放回栈顶的效果,所以除了把var1 - 1的值赋值给var4,同时var1 - 1的值依然在栈顶,所以此时依然为[var1 - 1]
local.tee 4
if ;; label = @1
loop ;; label = @2
local.get 2
local.set 3
i32.const 0
local.set 6
i32.const 10
local.set 7
loop ;; label = @3
local.get 3
i32.const 10
// 将var3 % 10 的值放到栈顶
i32.rem_u
local.set 5
local.get 3
i32.const 10
// 将var3 / 10向下取整的值放到栈顶
i32.div_u
local.set 3
local.get 5
local.get 6
// 取出栈顶头两个数执行 func 1,并把func1 调用的结果放到栈顶
call 1
local.set 6
local.get 5
local.get 7
call 0
local.set 7
local.get 3
i32.const 0
i32.gt_u
// 这里的意思是当栈顶数,var3 > 0的结果为0,(即false)时 跳出当前循环,br_if的 0 表示块的深度,以此类推0表示当前块,1 表示 上一块
br_if 0 (;@3;)
end
local.get 2
local.get 6
local.get 7
i32.mul
i32.add
local.set 2
local.get 4
i32.const 1
i32.sub
local.tee 4
// 如果栈顶数,即var4 - 1 == 0则跳出当前循环
br_if 0 (;@2;)
end
end
local.get 2)
// 把func2导出为Run,可以通过module.exports.Run获取
(export "Run" (func 2)))

以下为Run函数的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
function Run($0, $1) {
let $2;
let $3;
let $4;
let $5;
let $6;
let $7;
let delta;

$2 = $0;
$4 = $1 - 1;

{
do {
$3 = $2;
$6 = 0;
$7 = 10;
do {
$5 = $3 % 10;
$3 = Math.floor($3 / 10);
$6 = Math.max($5, $6);
$7 = Math.min($5, $7);
} while ($3 > 0);
$2 += $6 * $7;
$4 -= 1;
} while ($4 != 0);
}
return $2;
}

  1. WebAssemblyJS之间的通信

    1. JSWebAssembly之间较常见的是互传functionWebAssembly.Memory,通过上面说到的importsJS传给WebAssembly)和WebAssembly.Instance.exportsWebAssembly传给JS) 。
    2. 限制:函数的传参在这里的限制只能是使用上面提到的4种数据类型。那怎么去传递复杂的数据类型呢?方法是通过memory buffer,其可以JS端通过WebAssembly.Memory创建,或者WebAssembly端通过exports导出自己的内存,而这里关键是这个Memory是共享的,两端都可以进行操作。

      • 具体做法,通过wasm-bindgen生成js glue code里的工具方法说明:

        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
        // 用Uint8Array来表示wasm的buffer
        function getUint8Memory0() {
        if (cachegetUint8Memory0 === null || cachegetUint8Memory0.buffer !== wasm.memory.buffer) {
        cachegetUint8Memory0 = new Uint8Array(wasm.memory.buffer);
        }
        return cachegetUint8Memory0;
        }
        //设置字符串到`buffer`(可以把对象`stringify`以后作为字符串传入)
        function passStringToWasm0(arg, malloc, realloc) {
        // ....
        // 通过TextEncoder将字符串编码为UTF-8编码的Uint8Array
        const buf = cachedTextEncoder.encode(arg);
        // malloc为rust vm exports的方法,为字符串分配空间,分配空间的逻辑由rust完成
        const ptr = malloc(buf.length);
        // 返回ptr是Uint8Array的整数索引。相当于指针,这里把buf的值设置到wasm内存的这个区间里[ptr, ptr + buf.length]
        getUint8Memory0().subarray(ptr, ptr + buf.length).set(buf);
        WASM_VECTOR_LEN = buf.length;
        return ptr;
        // ....
        }
        // 如果是Uint8Array就更简单了,直接把数组的值设置到buffer即可
        function passArray8ToWasm0(arg, malloc) {
        const ptr = malloc(arg.length * 1);
        getUint8Memory0().set(arg, ptr / 1);
        WASM_VECTOR_LEN = arg.length;
        return ptr;
        }

        上面两个传递数据的工具函数都有两个关键的值,那就是ptrWASM_VECTOR_LEN,毫无疑问,这两个值都是整数类型,都可以通过函数进行传递,而实际数据就以Memory为介质,通过这种方法就解决了复杂数据传递的问题了。

后记

以上就是通过开发导出Excel需求后,对开发过程和WebAssembly学习的总结,可能WebAssembly在一般的前端开发里,比较少应用场景。但基于知识储备的考虑和兴趣,自然而然地就会去学习这个在2019年12月W3C认定为既html, css, js的第四种开发语言。

上面记述的内容主要是面向我自己的总结,可能并不那么详尽,不过至少理解了上面的内容以后,我大概明白了WebAssembly的工作机制。

下面是每周邮件中推荐的文章,同时亦时本文的参考,感兴趣的可以一读

  1. practical-guide-to-wasm-memory

  2. Learning WebAssembly Series

D3学习备忘

前言

在web前端开发中,时常会碰到显示图表的需求。而在这个领域中,国内最有名的库莫过于Echarts了,其他的还有Antg2highchartchart.js等,这些库的特点是开箱即用,基于配置,根据官方提供的demo,进行一定的调整就能够满足开发的任务的,也有提供自定义的方案,满足特殊的需求。

不过我总感觉不能满足于这种高级封装库提供的便利,因为这样比较难提升自己的开发能力,所以希望去折腾些较底层的库,让自己可以多动些脑筋。(也有一种潮流是直接使用React这类前端框架,做可视化的工作(同样也是数据驱动视图),不过这样子就相当于再造一个轮子了)

d3就是一个很好的选择,历史悠久,使用者众多,作者Mike Bostock也是个很有创造力的开发者,独力写了很多优秀的可视化demo,除了d3还创建了可视化分享平台Observerblehq

API设计方面采用了类似JQuery的链式调用,代码组织的特点是分成了多个独立的模块,分开仓库进行管理,所以也方便使用者按需引入。d3的类的创建,不使用new的方式,而通过函数返回一个新的对象,对象相关的变量,存储在闭包内。

我觉得d3主要是一个可视化数据处理工具函数的库,不提供图形渲染引擎,所以可以让使用者全盘掌控显示的内容。

API概念说明

d3.selection

d3的DOM操作的风格十分类似Jquery

  1. 创建DOM元素并返回一个类似Jquery集合的Selection对象
1
d3.create('svg')
  1. 选择DOM元素,selectselectAll都会返回一个Selection

    1
    2
    3
    4
    5
    selection.select('rect')
    selection.selectAll('rect')
    // 或者使用d3的静态方法
    d3.select('rect')
    d3.selectAll('rect')
  2. 绑定数据。绑定数据的方法有datadatum两种,data会返回一个新的集合并逐一把每一行数据绑定给selection中的成员,如原selection中的成员数量比data的行数小,则新创建的selection会使用占位用的empty补足;datum则是将整组数据逐一绑定给每一个selection成员,不会产生新的集合。

    1
    2
    3
    4
    let arr: any[]
    selection.data(arr)
    // 设置selection各成员绑定的数据
    selection.datum(arr)
  3. 绑定数据的行数与selection的数量有出入时,d3会产生enterexit的集合。但是并不会默认给enter的集合自动创建对应的DOM元素,这时可使用join去创建。

1
2
3
4
let arr: any[]
selection.data(arr).join('rect'/** 元素的标签名 */)
// 与上面等价
selection.data(arr).enter().append('rect')
  1. selection.call接收一个以selection为参数的回调函数,目前看来用途主要给selection添加一些子元素,这样就不会改变外部链式调用的主体。
1
selection.call((s) => s.append('path'))
  1. selection.each方法并不会回传一个子selection,而是回传三个参数d子data), i(索引),nodesGroupselection内部的DOM集合),回调函数的this等同于nodesGroup[i]
    1
    2
    3
    4
    selection.each(function (d, i, nodesGroup) {
    // 由于并不是子selection,所以需要进行select把DOM变成d3的selection对象
    d3.select(this)
    })

d3.Shape

  1. d3.lineRadiald3.line作用同样都是生成线段,不同的是lineRadial的坐标系是极坐标系,而且需要注意的是0 rad12点方向,角度增长为顺时针,每个点由angleradius方法定义。

d3.polygon

  1. d3.polygonCentroid,求多边形的中点a。

原理是以[P_(n - 1), P_n, [0, 0]]为顶点组成三角形,求出各个三角形的中点P_m_n(顶点坐标和除以3)。

然后各个顶点根据权重W(三角形n的面积 / 所有三角形的面积和)相加求得。

三角形的面积 = (V_(n - 1) X(叉积) V_n) * 0.5

KMP算法笔记

前言

最近一个月,坚持每天上Leetcode做一道算法题。这周每天推送的算法题,大部份都跟字符串有关且有两天的较困难的题目都用到了KMP算法,KMP算法步骤不算复杂,但也需要我花了点时间去理解,下面对我的理解进行记述。

主文

结合代码讲解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
String s = "babbbabbaba";
Integer len = s.length();
Integer fail[] = new Integer[len];
Arrays.fill(fail, -1);
Integer j = -1;
for (Integer i = 1;i < len;i++) {
j = fail[i - 1];
while (j != -1 && s.charAt(j + 1) != s.charAt(i)) {
j = fail[j];
}
if (s.charAt(j + 1) == s.charAt(i)) {
fail[i] = j + 1;
}
}

对于字符串s,KMP的核心任务是先构建一个fail数组,fail数组成员fail[i]的值k表示s[0:k - 1] == s[i + 1 - k:i + 1](以i为末位的长度为k的子字符串等于sk长前缀)。

上面12~13行比较好理解,由于上一次比较的结果是j = fail[i - 1],那假如这次比较也相等,自然fail[i] = fail[i - 1] + 1 = j + 1

nginx location指令配置的误解

源起

可能因为nginx轻量,功能齐全,跨平台,高性能(由C编写)的原因,在不同语言编写的web应用中,都能看到它的身影。

最近因为想用docker配置php服务器,最后的目标是放在线下的服务器中,为测试人员提供多个测试环境。

在这个过程中,由于我对nginx的location指令的理解有误,导致在转发资源的路由配置上卡了许久。

后面经过经过自己的调试和重读nginx的手册,终于弄明白了。

误区

  1. location指令是类似express(node.js)的中间件, 请求会在各个匹配的location中进行传递。

产生这个误解是因为看到这个配置:

1
2
3
4
5
6
7
8
9
10
11
# 这里只看到添加Expires的响应头,没看到返回响应主体的指令
location ~ .*\.(js|css|mp4)?$ {
expires 1h;
}

# 因为上面指令的存在,导致后面的这个指令一直未能匹配到
location ~ ^/resource/(.*) {
if (!-f $request_filename) {
rewrite ^/(.*?)/(.*?)/(.*?)/(.*)$ /code/mapi/$2/$3/$1/$4 last;
}
}

正解:对于每个请求,所有的处理都只会在一个location指令内完成所有的处理,上面第一条指令没有显式设置返回主体是因为默认是返回请求路径的静态文件。这一点类似webpackoutput设置public pathpath,默认是一个静态资源的转发服务器。

  1. location指令匹配的优先顺序,主要是看规则的匹配字符串的长度。不管规则是不是正则表达式,都会当成正则表达式处理(类似javascriptString.prototype.replace的第一个参数可以是字符串)。

但如果按照上述的逻辑看的,感觉有点与事实相悖,因为^/resource/(.*).*\.(js|css|mp4)?$,如果请求是/resource/teacher/3.0/areaSwitch.js,理论上两个规则都能匹配全部字符,那这时是按照怎样的规则呢?

在某个博客的文章中找到了如下的文字说明:

nginx location配置优先级

说是优先用正则表达式最长的那个。那把^/resource/(.*)改成/resource/teacher/3.0/(.*)是不是就可以了呢?发现也是不行。

最后只能在权威的资料(官方手册)中找答案了。

匹配逻辑只有简单的一段:

A location can either be defined by a prefix string, or by a regular expression. Regular expressions are specified with the preceding “~*” modifier (for case-insensitive matching), or the “~” modifier (for case-sensitive matching). To find location matching a given request, nginx first checks locations defined using the prefix strings (prefix locations). Among them, the location with the longest matching prefix is selected and remembered. Then regular expressions are checked, in the order of their appearance in the configuration file. The search of regular expressions terminates on the first match, and the corresponding configuration is used. If no match with a regular expression is found then the configuration of the prefix location remembered earlier is used.

最重要的是加粗的那段,翻译过来整个流程是:

  1. 首先遍历各个前缀匹配规则(所谓的前缀匹配,简单来说就是开头字符串匹配, 如/resource/teacher), 然后记下匹配到的最长的前缀。
  2. 然后进入到正则匹配阶段(遍历各个正则表达式的规则),只要发现有一个匹配则会停止遍历。
  3. 假如第2步找不到匹配,则应用第1步记下的那个最长的规则。
  4. 整个流程有两个特例,分别是规则中的=^~两个修饰符。^~表示假如当前规则是最长前缀,则跳过正则匹配阶段,直接应用当前规则;=则表示请求路径与规则字符相同,就直接应用当前规则。

总结

之前我一直被location的修饰符吸引注意力,而没留意整个匹配过程的细节,现在已豁然开朗。另外一点是查资料最好还是通过英文材料。

git submodule的使用经验

前言

上两周开始,我开始在实际项目中使用之前同事提到过的git submodule的代码管理方式。

最开始我引入这个命令的原因是前端的各Vue项目之间,目录结构和代码内容相似,甚至于有时候新开一个项目会直接复制旧项目的代码。一言而蔽之,那就是项目之间有着可共用的代码模块。

假如把其中的一些子目录抽出来作为一个独立仓库,主项目只进行引用,就可以避免这些可共用的代码不可靠地进行人工复制,代码分散管理更新的问题。

常用命令解释及易误解的地方

以下常用命令部分基本来自于官方指南

克隆远程仓库作子模块

1
git submodule add <remote-repo-url> [local-path=.]

显示子模块的diff信息
1
git diff --cached --submodule

克隆(git clone)带有子模块的项目后,需要初始化并拉取子模块
1
2
3
4
5
6
7
8
git submodule init
git submodule update
# 上面两命令的组合,一般直接用组合命令
git submodule update --init
# 又或者加上 --recursive 拉取嵌套的子模块(子模块本身又有子模块)
git submodule update --init --recursive
# 或者在clone时加上 --recurse-submodules选项自动完成拉取
git clone --recurse-submodules <main-project-repo-url>

当需要更新子模块时,切换到子模块的目录,使用常规的拉取上游更新的命令git fetchgit merge(或直接git pull
或直接使用git submodule update --remote [submodule-path]就不用切目录进行手动更新。

上面快捷命令默认拉取的是master分支,如果想默认拉取其他分支,则进行如下配置

1
2
# -f <git-modules-config-path> 表示指定.gitmodules文件路径
git config -f .gitmodules submodule.<submodule-name>.branch <branch-name>

进行下面的配置,可以让git status显示子模块的status
1
git config status.submodulesummary 1

当不想每次diff更新时都要添加submodule选项,可以进行如下配置

1
git config --global diff.submodule log

类似于git clonegit pull如果要同时拉取子模块,也需要添加相关的选项
1
2
3
4
git pull --recurse-submodules
# 或者分开进行
git pull
git submodule update --init --recursive

使用merge选项可以合并远程更新,如果不添加--merge,默认使用--checkout,直接检出commit。
1
2
# --remote 选项的作用是假如子模块有追踪远程的分支,那将会拉取该上游分支进行同步,否则会检出主模块当前commit对应的子模块的commit
git submodule update --remote --merge

如果发生冲突,可以跟普通仓库一样,到对应的子模块目录处理冲突,然后提交。由于子模块有新的提交(commit),回到主模块目录输入git status也会提示需要git add子模块的目录。

当在主仓库推送更新时可以使用recurse-submodules选项,避免没有推送子模块更新的情况。假如子模块更新没有推送到子模块的远程仓库,那么当其他成员,拉取主仓库时将会报错,提示不能从远程拉取对应的子模块的版本。

1
2
3
4
# 这个只会提示你有子模块还没推送
git push --recurse-submodules=check
# or
git push --recurse-submodules=on-demand

在实践中出现的疑问

Q1: git submodule跟直接clone子模块到子目录的区别在哪里。

A1:直接clone不能与主模块产生关联,而git submodule会在工程目录下添加.gitsubmoules文件,声明所有的子模块所在的目录及它们的远程仓库地址等相关信息。第二点,其实是理解submodule机制的关键,子模块不论因什么原因(未staged的change、untracked的文件、checkout到其他版本等)产生内容变化,在主模块目录输入git status都会显示子模块目录有更改。主模块在判断子模块是否有更改是与主模块当前版本对应的子模块版本进行比较。

主模块在git add文件时是不会添加子模块目录下的内容,而是直接add子模块目录。子模块目录以主模块的角度来看,是一个记录版本号的.txt文件,记录的版本号与子模块当前版本同步。

这一点可以在主模块拉取远程更新,子模块目录发生冲突的时候可以体现。显示的冲突会显示成类似下面的形式,下面的英文字符是commit的SHA:

1
2
3
4
<<< a/src/components (Current Change)
+++ abcdefghijklmnopqrst
>>> b/src/components (Incoming Change)
+++ ghijklmnopqrstuvwxyz

Q2:怎么把分支B的子目录变成子模块,假设分支A的该子目录已经是子模块了。

A2:切到分支B,然后checkout 分支A的.gitmodules文件(注意这里如果直接初始化(git submodule update --init)子模块是无效的,道理可参见上一个问题的解释,因为此时子模块没有指定一个版本)。切到分支B后,有两种方式完成这个任务:

  1. 删除子目录的内容,通过 git submodule add 克隆远程仓库到指定子目录
  2. checkout分支A的.gitmodules文件(注意这里如果直接初始化(git submodule update --init)子模块是无效的,道理可参见上一个问题的解释,因为此时子模块所在目录没有任何记录为子模块的历史,没有版本绑定)。然后checkout 分支A的对应子目录即可。

小提示

  • submodule下有一个foreach的子命令挺实用的,它的机制类似于cd到每个子模块目录,然后执行一段shell脚本。

    • example:
      1
      2
      # 显示各个子模块的status
      git submodule foreach 'git status'
  • 官方指南上记载了三个git alias的配置,可以使用缩写执行一些常用命令。
    1
    2
    3
    4
    5
    # ! 表示命令可以是普通的shell命令而不用是git 的子命令
    # 在windows中需要把'!'"git diff ..." 改成 "! git diff ..."
    git config alias.sdiff '!'"git diff && git submodule foreach 'git diff'"
    git config alias.spush 'push --recurse-submodules=on-demand'
    git config alias.supdate 'submodule update --remote --merge'

附记

最开始公司里提出用submodule并不是为了共用代码,而是为了在前后端代码并存的项目中,对前后端代码分别使用子模块管理,进行分隔,互不干涉。

对于共用代码的方案,git还有一个好像在某些版本不是内置的命令subtree,稍微了解了一下,好像subtree会把子模块的内的文件更改也记录到主模块中。个人觉得submodule的对于各模块的分割功能更好,而且是git内置的,算是标准功能吧。

虽然submodule的使用需要一点额外的学习成本,但只要了解了它的机制,用起来也能得心应手的。

Vue应用性能调优技巧

前言

这星期发现开发的某个页面的复选框的点击反馈比较的慢,为了给用户提供更好交互体验的信念,同时也为了验证积累的Vue框架的知识,决定改善这个部分的代码写法。而这个页面最开始是其他同事开发的,可能团队中一直都没有意识到这个问题,所以趁此机会分享这次调优的过程。

案例分析

原案例我把它稍简化以后放到codepen进行展示。


See the Pen
speed up the interaction of a view with many checkboxes
by Sociosarbis (@sociosarbis)
on CodePen.

经过一些实验以后,有以下的发现

  • 性能的瓶颈其实大部分都来自于DOM操作和渲染
  • 依据视图依赖的数据,拆分独立组件,可以减少diff次数(这点其实之前已经有了解)

这次调优技巧涉及到3点

  1. 把复选框的每个组做成一个独立的组件。

vue更新的大致机制是:

  1. 在运行时或者编译时最终都会把template,转换成组件的render函数
  2. 执行render函数时返回虚拟DOM,在这过程中会收集render函数中的数据依赖(收集原理可参考通过简化版的Observer的实现来说明vue的watch的工作原理), 当依赖数据改变时,再次执行render函数得到新的虚拟DOM,与旧虚拟DOM进行diff更新。
  3. 假如render函数中出现的自定义组件的props不发生改变,是不会去执行自定义组件的render函数,意思是不会diff更深一层,这样就能减少diff的次数。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
Vue.extend({
template: `<el-tabs v-model="activeTab">
<el-tab-pane class="tab__pane" v-for="tab in tabs" :key="tab.name" :name="tab.name" :label="tab.name + '(' + tabCount(tab) +')'">
` + /*<div v-for="group in tab.groups" :key="group.name">
<div><el-checkbox :value="group.selectedMembers.length === group.members.length" @input="group.selectedMembers = $event ? group.members : []" @click.native="startInteraction"/>{{group.name + '(' + group.selectedMembers.length + ')'}}</div>
<el-checkbox-group v-model="group.selectedMembers" @click.native="startInteraction">
<el-checkbox v-for="(member, index) in group.members" :label="member" :key="index" />
</el-checkbox-group>
</div> */ + `
</el-tab-pane>
</el-tabs>`,
// ...
});

把注释掉的部分抽出改成select-group组件

1
2
3
4
5
6
7
8
9
Vue.component("select-group", {
template: `<div><div><el-checkbox :value="group.selectedMembers.length === group.members.length" @input="group.selectedMembers = $event ? group.members : []" @click.native="$emit('click')"/>{{group.name + '(' + group.selectedMembers.length + ')'}}</div>
<el-checkbox-group v-model="group.selectedMembers" @click.native="$emit('click')">
<el-checkbox v-for="(member, index) in group.members" :label="member" :key="index" />
</el-checkbox-group></div>`,
props: {
group: Object
}
});

  1. 取消复选框的过渡动画
  • 取消动画的原因是在全选/取消全选的时候,大量的复选框(大量的DOM对象)会影响渲染速度,过渡动画会变慢,取消以后,视觉上的反馈速度会快许多。
    1
    2
    3
    4
    5
    6
    7
    .el-checkbox__input.is-checked .el-checkbox__inner, .el-checkbox__input.is-indeterminate .el-checkbox__inner {
    transition: none !important;
    }

    .el-checkbox__inner::after {
    transition: none !important;
    }
  1. 对tab使用v-if和keep-alive
  • v-if控制不生成无需显示的虚拟DOM,这样在点击全局全选/取消全选(会影响所有tab的数据)的时候,不用去做其他tab的diff。
  • 使用keep-alive的原因是使用v-if以后,切换tab会有较大的延迟(原因是有大量的DOM的创建),所以使用keep-alive缓存组件,除了第一次由于无缓存而比较迟缓外,后续的切换速度还是可以的。
  • 在keep-alive上最开始我犯了个错误,在keep-alive下面放div,发现并没有任何提升效果,看了文档发现keep-alive下面需要放自定义组件
  • 这个方案不太具通用性,但Vue没有shouldComponentUpdate的更新控制,不过也算是个可考虑的技巧。

写在最后

前端框架虽然减少了开发者的工作量,但也有执行效率不那么高效的一面(需要遍历diff而不是直接命令式的指定某个DOM更新),为了给用户更好的用户体验,成为更好的开发者,需要在调优上多花点心思。

附:

  • 其实DOM的渲染也是影响性能的一个重要因素。这个论点的根据时,当我把所有tab的display设为none后,发现切换的速度会有很大的提升。

Yox框架研究(1)—模板表达式编译篇

缘起

在google上搜索 “vue不能兼容IE8”,在知乎上看到Yox的作者在推他写的这个框架。这个框架的特点是属性方法设计基本与vue一致,模板语法参照handlebar,能兼容IE6以上的浏览器。
值得一提的是,Yox的作者对自己的作品很有自信,且声称该框架一直用在自己的工作中。

阅读框架源码,发觉他的自信是有道理的,理由有:

  1. 代码使用typescript编写
  2. 代码组织清晰,变量命名简单易懂,少新造概念,并且有足够的注释
  3. 有独特的设计,如使用handlebar的模板语法,在列表渲染中有类似变量作用域的语法设计("../name"表示使用上一层的name属性)
  4. 整个框架基本由他一人开发

因为有这些特点,让我觉得这是一个值得深入学习的项目。

正题

模板编译的工作是把模板字符串转成函数代码,我看过的一些模板引擎ejspugvueyox都是同样的做法。
yox把模板表达式的编译部分拆分成独立的模块(yox-expression-compiler),整个模板的编译为yox-template-compiler模块。

yox-expression-compiler模块包含了三个感觉比较重要的概念:

  1. compiler:解析表达式
  2. creator:创建组成表达式的各类节点,如字面量、标识符、函数调用节点

节点,其实就是结构对象:

1
2
3
4
5
6
7
function createLiteral(value: any, raw: string): Literal {
return {
type: nodeType.LITERAL,
raw,
value,
}
}

  1. generator: 将节点转换成代码字符

creator和generator相对来说没那么复杂,而compiler则负责代码扫描解析逻辑的任务。

compiler的设计

  1. 游标移动
    1. go:前进后退
    2. skip: 跳过空白字符
  2. token类型判断——scanToken,有下列情况:
    1. identifier(标识符,如a, name
    2. literal(字面量)
      1. number
      2. 字符串
      3. 数组
      4. 对象
    3. 一元运算符(二元运算符的提取会在scanBinary
    4. 特殊字符
      1. (xx),括号
      2. .,../,表示上面提到的作用域切换或者'.'开头的数字
  3. 运算式的解析——scanTernaryscanBinary
其他规则:
  1. 当遇到idenfier或者字面量(除number对象外),还会进行scanTail逻辑(意思式检测后面是否接着.[]这样的取成员的表达式以及(a,b,c)这样可能的函数调用表示,如果有则会组成一个新的节点)
  2. 当下一个接的值是可能的任意值时(如对象的属性值,数组的成员,函数参数),都会调用scanTernary,所以在启动编译时,第一步就是执行这个方法。可能的解释是三元表达式是包含内联代码所有可能的表达式,所以先假定是三元表达式,如果不符合条件再fallback到其余情况。
比较有启发性的解析方法:
  1. 对象解析:
    对象分为key和value,所以在解析时会在keyvalue两个模式中进行切换,初始时key,遇到:value,遇到,key。解析到keyvalue时,会分别添加到keysvalues数组。当遇到}闭合字符时,根据两个数组中成员的个数,判断对象是否合法。
  2. 二元运算式解析:
    运算式由运算数(operand)运算符(operator)组成,先解析运算数后解析运算符,而二元运算符有优先级的问题(a + b * c + d,应该先运算b * c)。

    解决的方法为源码中提到的Shunting-yard algorithm

    中心思想是,运算数运算符会按顺序push到数组中,确保扫描到的当前运算符的优先级小于前一个运算符,否则则将前一个二元运算提出来作为一个新的Node(如上面+ d+

    (中文译作调度场算法,一种将中缀表达式a + b转成后缀表达a b +的算法,之前好像有在线上课程中提到过,应该是计算机系的课程内容。

    wiki上说中缀表达式不易被电脑识别,但感觉这不构成在这里使用这个算法的理由,毕竟似乎 “假如当前运算符的优先级高于前一个,则将后面的二元运算式提出”也行得通。不过可能这样的话,由于还不知道下一个操作数,不好做处理,所以选用了这个算法)。

    当后面不再有二元运算式时,再从数组后面取出组成一个个的二元运算式节点。

compiler的阅读体会

  1. 对于代码表达式来说,通常都会有开始标志和结束标志的这样成对的设计,如html标签<div></div>,字符串'a',"abc",对象{ a: 1 },数组[ 1, 2, 3 ],函数参数(1, 2, 3)
  2. 之前觉得像对象那样的嵌套结构挺难处理的,但事实上这种情况完全符合递归的场景,只需要再调用根函数即可,像上面说的scanTernary

后记

下一篇应该是关于整个template编译的学习。现在开始需要多学习框架或者大项目的设计模式,这样才能学会独立从零到一开发项目。

按照官方指南实践vue SSR

前言

年末跟新公司的同事聊过node中间件和服务端渲染的问题,大概的意思就是将原来nginx的特定路由的分发,交由我们前端组负责,使用node代替的想法,希望前端组能够不依赖后端的同学,独立完成一些服务端的工作,方便后面去做服务端渲染。
于是,趁着春节假期,在家里花点时间,做一下vue的服务端渲染的实践。

实践笔记

由于之前的项目一般都是通过vue-cli去生成的,所以初步的想法是思考将已有项目改为SSR的方法。
于是实践便由vue create ssr-demo命令生成的项目开始了。
第一步,一般是去查看官方指南。可喜的是,在某一页中看到了一个官方demo的github链接,有示例项目作为参照,认识会更具体一些。

SSR大体来说是用同一份代码,分别以server环境和client环境为目标做两次打包(具体来说可以是使用两份配置,运行两次webpack),server端收到请求运行对应环境的bundle,render出html,发回client端;而html里包含了client环境bundle的script,css资源的链接。加载完script以后会hydrate(激活)服务器渲染出来的app的html(如给DOM添加listener)。

所以在改造项目时,需要意识到server环境和client环境的API的不同
改造过程中已知的两个环境的不同点有:

  1. server端在渲染component时,生命周期只会进行到created,所以最好是做到全局和周期created之前的代码是环境通用的代码。
  2. 需要预获取的数据的component,可以在构造options提供serverPrefetch的option,这是一个this指向vue instance的function,如果返回一个promise,会等这个promise resolved后再去做component的render。
  3. client端的bundle的webpack config一般可能会加splitChunks来做代码分割,可是server端的bundle则需要将所有代码打包成一块,所以不做代码分割。
  4. server端的入口文件需要export一个接收ssr context对象,返回可resolve出app实例的promise。
  5. server端需要添加VueSSRServerPlugin,webpack打包最终生成一个json;client端则添加VueSSRClientPlugin,除了生成的js,css等文件以外,还会生成一个client-manifest的json文件(用于提供生成的文件的信息,让服务端渲染的html能够正确插入client端生成文件资源的标签)。
  6. server端的配置还需要在DefinePlugin中加上Process.env.VUE_ENV='server'的配置。

共同点

  1. 由于生成的html由服务端渲染而成(或者说由服务端渲染负责),所以原有的htmlWebpackPlugin也去掉。而prefetch,preload和pwa的插件依赖于htmlWebpackPlugin,所以在配置中都要去掉。

遇到的问题和解决方法

  1. vue-cli生成的项目,除非释出webpack的配置,否则都是通过创建的vue.config.js对webpack进行调整。而当在开发的时候,因为不去用默认的webpack的devServer,所以需要获取根据vue.config.js生成的webpack配置。而@vue/cli-service/lib/Service的Service实例提供了一个
    resolveWebpackConfig的方法,去得到相应的wepack配置。
  2. 第二个问题时1中的Service实例怎么找到对应的vue.config.js文件呢?现在有client和server两个config,需要动态替换vue config来生成webpack配置。通过阅读网上方案和@vue/cli-service/lib/Service的源代码,得知可在创建Service实例前,通过改变process.env.VUE_CLI_SERVICE_CONFIG_PATH为对应vue.config.js绝对路径,便可实现动态切换需求。
  3. 1中提到需要自己去写一个devServer,主要就是要实现打包资源请求的响应和模块热更替。修改的关键是添加webpack-dev-middleware,webpack-hot-middlewareHotModuleReplacementPlugin
    1. webpack-dev-middleware的功能是处理打包资源请求的响应,同时也把compiler的fileSystem改为memory-fs(将打包的文件缓存在内存里),如果有与请求匹配的打包后的asset,则将之返回。
    2. webpack-hot-middleware的是做监听webpack的recompile事件,然后通知客户端的工作。
    3. HotModuleReplacementPlugin则是给文件的module添加hot的属性,提供一些关于请求更新后的模块资源等API,webpack-hot-middleware包含了这些API的使用,所以一般不需要自行配置。
  4. 虽然3中的webpack-hot-middleware帮忙处理了webpack的recompile的模块更新问题,不过还有一个index.template.html文件需要做热更新,在这里是使用chokidar做文件更新监听,再利用webpack-hot-middlewarepublish方法通知客户端。
  5. 到了生成production环境文件的时候,依然还是使用vue-cli进行生成,所以像2中所述,需要在package.json的script中填写vue.config.js绝对路径。跨平台的环境变量设置可以使用cross-env,但是要得到这个绝对路径,需要知道工作目录。但是在不同平台的工作目录变量的名字可能不一样,所以这里使用了$INIT_CWD这个变量。$INIT_CWD指的是npm指令运行时的工作目录
  6. 当我尝试做组件的Data Prefetch时,使用了'/topNews'这个相对的url,在client端是没问题的,会带上host和port,但是在server端,他没有这个context,如果是用相对的url,host会是localhost(这个没有问题),但是port会是默认的80,所以需要在axios那里根据是否是server环境,去加上如http://localhost:8080的前缀。

写在最后

项目放在github,大部分参考自官方的demo,已完成项目框架的搭建。后面假如说真的有需要的话,再进一步完善本项目。
后面可做的有:

  1. 做成vue-cli的preset,一件生成目录结构。
  2. 添加server的proxy和cache功能。
  3. 配置作为业务模块分发的路由。

一种2D装箱算法

前言

前段时间在做项目的时候,遇到某组图标需要从iconfont改为使用png的情况。这种情况,由于图标本身是比较小的而数量又比较多,为了减少大量http请求的成本,需要把这些图标做成一张sprite图。在不劳烦UI的同学的情况下,那我自行去生成了。
本身就有一些现成的网站有这类的服务了,如toptal.com;命令行的话,有spritesmith。其中里面的有一个叫binary-tree的排列方式引起我的兴趣,查看spritesmith的源码,他其实是用了bin-pack提供的算法的。下面开始介绍这种算法。

算法思路

  1. 把图片数组按照它们的面积大小,从大到小排列,并把最大的那张图片放到左上角,并把他的x,y,width,height作为初始root的数值
  2. 遍历所有的图片,一个个对他们进行放置。
  3. 放置的规则是从root出发,
    1. 如果当前遍历的图片的width和height都不大于当前node条件1), 把当前的遍历的图片A放在node的左上角, 然后进行分裂操作,给node添加downright两个子node, 分别为A的右上方到node的右下方
      A的左下方到node的右下方两个区域。
    2. 第1步中用的是当前node这个词的原因是,假如一个node(包括root)分裂过,那么第一步就会从他的down或者right的子node中,寻找具有足够空间放置的一方(是一个递归的过程)。
    3. 假如不满足条件1,root就会进行grow操作,grow在放置新图片在右方还是下方的问题上会考虑两个因素:
      1. 如果新图片的宽小于root的宽表示可以向下增长,高小于root的高,表示可以向右增长。
      2. 第二个是非必要因素,主要是遵循增长以后root的宽高,差距能缩小的原则。假设第1步显示两个方向都允许的,但如果往右增长,高依然是大于宽的,那就会优先往右增长。增长分别就相当于在root的右上角和左下角,放置新的那个图片。同时根据放置的位置,更新root的right或者down 子node的位置和大小

想法

在bin-pack的github上也提到了这个算法提供的并不是最优解,但是算法十分简洁,效果也能在使用它的库中得到体现,可以说是相当实用的,也给我们在解决这类问题的时候,提供了一个行之有效的方法。

从whistle热重载插件到websocket工作原理

前言

  • 工作上有些需求是需要去改后端渲染的文件的,但是由于不是通过webpack开发,没有修改完立刻更新页面的功能,所以显得不是太方便。要实时更新页面可以通过热重载或热更新,热重载比较简单,其实就是页面自动reload,热更新则需要重新打包已更改的文件,然后通过websoket发送新的补丁,完成更改。

需要实现的功能

  • 先不谈复杂的热更新,对于后端渲染的这类比较简单的页面,热重载已经能够很好地方便我们的开发了。
  • 要做热重载,需要做以下两个功能:
    1. 监听文件的更改
    2. 通知页面进行reload
  • 第一点比较简单,可以使用fs.watch或者跨平台的库chokidar,都可以进行对文件更改的监听。
  • 第二点对页面进行通知,我们平时用webpack开发的时候自然会发现页面会打开一个websocket的连接,而这个连接就是起服务器与页面客户端间通信的作用。

websocket的工作流程

  • 下一步就是怎么创建一个websokcet的连接的问题,分为两个部分,server和client
  • client:
    • 对于比较简单的应用,例如这种通知更新的,可以简单地使用浏览器提供的Websocket的api
  • sever:

    • 首先node没有提供直接的api,可以用第三方库或者自己实现。对于第三方库,比较著名的有socket.io,不过需要在页面中使用客户端对应的库,所以不作考虑。而留意到webpack-dev-server用到的库是sockjs-node, 使用浏览器的api就可完成连接,所以这里就选用该库。
    • 先来看一下简单用例①:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      const http = require('http');
      const sockjs = require('sockjs');

      const echo = sockjs.createServer({ prefix:'/echo' });
      echo.on('connection', function(conn) {
      conn.on('data', function(message) {
      conn.write(message);
      });
      conn.on('close', function() {});
      });

      const server = http.createServer();
      echo.attach(server);
      server.listen(9999, '0.0.0.0');

      如果了解过node.js,http.createServer的作用是创建一个http的服务器,那为什么又有一个类似的sockjs.createServer的方法,难道真的是创建多一个服务器吗?
      带着这个疑问,翻看源码:

      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
      class Server extends events.EventEmitter {
      constructor(user_options) {
      super();
      this.options = Object.assign(
      {
      prefix: '',
      transports: [
      'eventsource',
      'htmlfile',
      'jsonp-polling',
      'websocket',
      'websocket-raw',
      'xhr-polling',
      'xhr-streaming'
      ],
      response_limit: 128 * 1024,
      faye_server_options: null,
      jsessionid: false,
      heartbeat_delay: 25000,
      disconnect_delay: 5000,
      log() {},
      sockjs_url: 'https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js'
      },
      user_options
      );
      ...
      this.handler = webjs.generateHandler(this, listener.generateDispatcher(this.options));
      }
      }

      可以看到它并没有做任何与连接相关的工作。然后再看到用例①中对于sockjs创建的这个“server” ,还有一步是echo.attach(server),看来这里才是“sockjs sever”工作的入口。源码如下:

      1
      2
      3
      4
      5
      6
      ...
      attach(server) {
      this._rlisteners = this._installListener(server, 'request');
      this._ulisteners = this._installListener(server, 'upgrade');
      }
      ...

      原来是对http serverrequestupgrade事件做监听。
      request是收到http请求时触发的,那upgrade呢?

      根据node的文档所述:

      Emitted each time a server responds to a request with an upgrade.

      这里的request with an upgrade,通过后面的example,粗浅地可以认为是Connection header为'Upgrade',并且还有一个Upgradeheader的request

      1
      2
      3
      4
      5
      6
      7
      8
      const options = {
      ...
      headers: {
      'Connection': 'Upgrade',
      'Upgrade': 'websocket'
      }
      };
      const req = http.request(options);
    • request事件的callback参数为request和response,而upgrade事件则是request,socket和head。第二个参数由response变为socket,而这个socket参数就是client和server间的TCP连接,而websocket就是对这个TCP连接的socket对象进行操作,根据websocket协议的规则,对socket对象中通信的数据进行解析读入和封装用户的消息进行写入。
    • 顺带一提,对于要upgrade为websocket的请求,服务端也会写入符合http规则的响应报文(而这个过程称作建立websocket连接的握手),并且不会调用response.end或者说socket.end去结束服务端和客户端的连接。

      1
      2
      3
      4
      socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +
      'Upgrade: WebSocket\r\n' +
      'Connection: Upgrade\r\n' +
      '\r\n');

      ps:建立websocket的request还会有sec-websocket-versionsec-websocket-key等headers,而response还会有sec-webSocket-accept等headers,由于本文主旨在于建立websocket的通信流程的概念,所以具体的协议标准等知识,可自行search。

    • 建立了websocket的连接,下面就是要知道是怎么从socket收到消息和写消息到socket中。这两个步骤对应于这两行来自faye-websocket\lib\faye\websocket\api.js的代码(faye-websocketsockjs-node的依赖库)

      1
      2
      this._stream.pipe(this._driver.io);
      this._driver.io.pipe(this._stream);

      this._stream就是TCP的socket(也是流对象),而this._driver.io是一个双工(Duplex, 意为可读可写)的流。
      ps:流就是nodejs的Stream类。

    • 之前我看到pipe这个方法,对他的机制有点摸不着头脑,不知道他是在什么时候才会把数据传到可写流(Stream.Writable)中。要解决这个疑问,只需要看到这一步,就知道触发可读流(Stream.Readable)data事件时,就会把数据写到可写流中。而data事件的其中一个触发时机,就在Readable的read方法中。而pipe方法在最后,会通过resume方法,让流进入flowing mode,这个mode简单来讲就是假如Readable的Internal buffer(内部缓存),就会通过循环不断地调用read。
      最后一个疑问他内部缓存地数据又是从哪里来的呢?
      答案是在read方法中,会调用_read方法。_read方法的作用是调用Readable.push方法,把数据放到Internal buffer里。可能这也是为什么在继承或者实现Readable的时候,需要去实现一个_read方法来获取自定义数据。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      // https://github.com/nodejs/node/blob/master/lib/_stream_readable.js
      src.on('data', ondata);
      function ondata(chunk) {
      ...
      const ret = dest.write(chunk);
      ...
      }
      ...
      if (!state.flowing) {
      debug('pipe resume');
      src.resume();
      }

      return dest;

      websocket的内容就介绍到这里,下面是要讲的是完成热重载需求的whistle插件的开发。

      为了更好地理解下面的内容,可先阅读whistle文档中插件开发的部分。

如下是我在看过文档,github上的demo和whistle的源码后的几点所得。

  1. whistle的插件实际上就是让插件export一个接收server对象的函数, 然后自己编写对server对象事件响应的callback,事件主要是request和connect,request就是普通的http请求,connect可以是websocket或者tunnel请求(ps:没了解过tunnel请求)
  2. 每个插件都会创建一个监听本地新端口的入口server,其主要的作用是把请求分发给它其下的子server,而这些server虽然都是真的http server,但它们不会监听端口,在请求的不同阶段,入口server会把请求分发到对应的子server,子server有如下这些:
    1. uiServer
    2. reqRead
    3. reqWrite
    4. resRead
    5. resRulesServer
    6. resStatsServer
    7. resWrite
    8. rulesServer
    9. server
    10. statsServer
    11. tunnelReqRead
    12. tunnelReqWrite
    13. tunnelResRead
    14. tunnelResWrite
    15. tunnelRulesServer
    16. wsReqRead
    17. wsReqWrite
    18. wsResRead
    19. wsResWrite
      但问题是具体需要怎么响应对这些server的请求事件,文档里没有说明。只能在提供的demo中找到,某些server具体需要response什么东西。
      例如'rulesServer'结尾的server,需要response如下的JSON数据来动态地去添加whistle的规则。
      1
      2
      3
      4
      5
      6
      7
      8
      JSON.stringify({
      rules: `${req.headers.host}/sw-register.js file://{sw-register}
      ${req.headers.host}/sw.js file://{sw-content}`,
      values: {
      'sw-register': registerContent,
      'sw-content': content
      }
      }, null, 4)
      rules是whisle规则的文本,values则是用于替换规则中的变量。
      所以如果我们需要给我页面注入,websocket client的代码的话就在ruleServer中response.end(rules)就可以实现了。
  3. 规则的值,如www.ifeng.com method://post中的post,可通过req.originalReq.ruleValue获取。
  4. server当中有两个比较特别的,一个是uiServer,需要写一个完整的有前端页面的web应用,让用户修改插件的配置,给其他server去获取使用。另外一个是上面No.9的”server”,这个相当于一个代理的服务器,他的response会成为最后whistle传回来的那个response,其他的如ruleServer,就只是像上面说的只是通过resposne来增加一些临时的新规则。
  5. *Read,*Write这两种结尾的server,本人到目前为止还不清楚到底要怎么用。

下面是这个插件简单的实现代码:

whistle.hot-reload-plugin

总结

  1. 虽然这个功能很简单,但是它涉及到的知识还是很多的,而且还没有完全弄明白, 后面继续学习。
  2. whistle的代码目前对我来说还是比较复杂的,涉及到协议的不同规则,请求转发, stream pipe等web后端的知识。