jrainlau

jrainlau 查看完整档案

深圳编辑华南师范大学大学城校区  |  光信息科学与技术 编辑腾讯  |  前端 编辑 jrainlau.github.io 编辑
编辑

Hiphop dancer,
Front-end engineer,
Wanna be a designer.

个人动态

jrainlau 发布了文章 · 2月20日

奇怪的知识——位掩码

108588108-64e3c400-7392-11eb-8275-ed7a7d4a3ebd.png

在春节假期无聊刷手机的时候,偶然间看到了一篇关于“位掩码”的文章,本身就是奇怪知识的它可以用来解决一些奇怪的问题,实在是非常有趣。

位运算符

在了解“位掩码”之前,首先要学会位运算符。

我们知道,在计算机中数据其实都是以二进制的形式所储存的,而位运算符则可以对二进制数据进行操作。举个简单的例子,给定两个二进制数据(其中 0b 是二进制数据的前缀):

const A = 0b1010
const B = 0b1111

image.png

1、按位非运算符 ~

对每一位执行非(NOT)操作,也可以理解为取反码。
image.png

2、按位与运算符 &

对每一位执行与(AND)操作,只要对应位置均为 1 时,结果才为 1,否则为 0。
image.png

3、按位或运算符 |

对每一位执行或(OR)操作,只要对应位置有一个 1 时,结果就为 1。
image.png

4、按位异或运算符 ^

对每一位执行异或(XOR)操作,当对应位置有且只有一个 1 时,结果就为 1,否则为 0。
image.png

5、左移运算符 <<

将数据向左移动一定的位(<32),右边用 0 填充。
image.png

6、右移运算符 >>

将数据向右移动一定的位(<32),遗弃被丢出的位。
image.png


在学习完了位运算符以后,肯定有人会说,道理都明白了,那么这些位运算符有什么用呢?应该在什么场合使用呢?平时的业务开发中也没见过,是不是其实学了也没什么用?

对于这个问题,答案确实是“是的,这个知识其实没什么用“。但是呢,秉承着探索的精神,我们也许可以用这个”没什么用的知识“去解决一些已知的问题。当然,在后续的例子中,你可能会觉得我在小题大做。不过没关系,学习本来就是枯燥的事情,能够找到些有趣的方式去学习枯燥的知识,也是很快乐的

权限系统

假设我们有一个权限系统,它通过 JSON 的方式记录了某个用户的权限开通情况(姑且假设权限集是 CURD):

const permission = {
  create: false,
  update: false,
  read: true,
  delete: false,
}

如果我们把 false 写成 0,true 写成 1,那么这个 permisson 对象可以简写为 0b0010

const permission = {
  create: false,
  update: false,
  read: true,
  delete: false,
}

// 从左往右,依次为 create, update, read, delete 所对应的值
const permissionBinary = 0b0010

对于 JSON 对象的权限集,如果我们要查看或者修改该用户的某些权限,只需要通过形如 permission.craete 的普通对象操作即可。那么如果对于二进制形式的权限集,我们又应该如何进行查看或者修改的操作呢?接下来我们就开始使用奇怪的知识——位掩码来进行了。

位掩码

首先进行名词解释,什么是”位掩码“。

位掩码(BitMask),是”位(Bit)“和”掩码(Mask)“的组合词。”位“指代着二进制数据当中的二进制位,而”掩码“指的是一串用于与目标数据进行按位操作的二进制数字。组合起来,就是”用一串二进制数字(掩码)去操作另一串二进制数字“的意思。

明白了位掩码的作用以后,我们就可以通过它来对权限集二进制数进行操作了。

1、查询用户是否拥有某个权限

已知用户权限集二进制数为 permissionBinary = 0b0010。如果我想知道该用户是否存在 update 这个权限,可以先给定一个位掩码 mask = 0b1

image.png

由于 update 位于右数第三项,所以只需要把位掩码向左移动两位,剩余位置补0。最后和权限集二进制数进行按位与运算即可得到结果。
image.png

最后算出来的 result 为 0b0000,使用 Boolean() 函数处理之即可得到 false 的结果,也就是说该用户的 update 权限为 false

// 从左往右,依次为 create, update, read, delete 所对应的值
const permissionBinary = 0b0010

// 由于 update 位于右数第三位,因此只需要让掩码向左移动2位即可
const mask = 0b1 << 2

const result = permissionBinary & mask

Boolean(result) // false

2、修改用户的某个权限

当我们明白了如何用位掩码来查询权限后,要修改对应的权限也就手到擒来了,无非就是换一种位运算。假设还是 update 权限,如果我想把它修改成 true,我们可以这么干:

image.png

只需要把按位与改为按位异或即可,代码如下:

// 从左往右,依次为 create, update, read, delete 所对应的值
const permissionBinary = 0b0010

// 由于 update 位于右数第三位,因此只需要让掩码向左移动2位即可
const mask = 0b1 << 2

const result = permissionBinary ^ mask

parseInt(result).toString(2) // 0b0110

经过上面的内容,相信你已经基本掌握了位掩码的知识,同时你肯定还有很多问号,比如说这么复杂又不好阅读的代码,真的有意义吗?

脏数据记录

前文例子中的权限系统仅有区区4个数据的处理,位掩码技术显得复杂又小题大做。那么有没有什么场景是真的适合使用位掩码的呢?脏数据记录就是其中一个。

假设我们存在着一份原始数据,其值如下:

let A = 'a'
let B = 'b'
let C = 'c'
let D = 'd'

给定一个二进制数,从左往右分别对应着 A/B/C/D 的状态:

let O = 0b0000 // 十进制 0

则数据一旦发生了修改,都可以用对应的比特位来表示

// 当且仅当 A 发生了修改
O = 0b1000 // 十进制 8

// 当且仅当 B 发生了修改
O = 0b0100 // 十进制 4

// 当且仅当 C 发生了修改
O = 0b0010 // 十进制 2

// 当且仅当 D 发生了修改
O = 0b0001 // 十进制 1

同理,当多个数据发生了修改时,则可以同时表示

// 当 A 和 B 发生了修改
O = 0b1100 // 十进制 12

// 当 A/B/C 都发生了修改
O = 0b1110 // 十进制 14

通过这个思路,应用排列组合的思想,可以很快知道只需要仅仅 4 个比特位,就可以表达 16 种数据变化的情况。由于二进制和十进制可以相互转化,因此只需要区区 16 个十进制数,就可以完整地表达 A/B/C/D 这四个数据的变化情况,也就是脏数据追踪。举个例子,给定一个脏数据记录 14,二进制转换为 0b1110,因此表示 A/B/C 的数据被修改了。

Svelte 这个框架,就是通过这个思路来实现响应式的:

if ( A 数据变了 ) {
  更新A对应的DOM节点
}
if ( B 数据变了 ) {
  更新B对应的DOM节点
}

/** 转化成伪代码 **/

if ( dirty & 8 ) { // 8 === 0b1000
  更新A对应的DOM节点
}
if ( dirty & 4 ) { // 4 === 0b0100
  更新B对应的DOM节点
}
更多具体的介绍可以查看《新兴前端框架 Svelte 从入门到原理》

老鼠喝毒药

除了用来做脏数据记录以外,位掩码也能够用来处理经典的”老鼠喝毒药“的问题。

有 1000 瓶水,其中有一瓶有毒,小白鼠只要尝一点带毒的水24小时后就会死亡,问至少要多少只小白鼠才能在24小时内鉴别出哪瓶水有毒?

我们简化一下问题,假设只有 8 瓶水,其编号用二进制表示:

image.png

接着按照图示的方式对水瓶的水进行混合,得到样品 A/B/C/D,取4只老鼠编号为 a/b/c/d 分别喝下对应的水,得到如下的表格:

image.png

在 24 小时候,统计老鼠的死亡情况,汇总后可以得到表格和结果:

image.png

答案呼之欲出,由于 8 瓶水可以兑出 4 份样品,因此只需要 4 只老鼠即可在 24 小时后确定到底哪一瓶水是有毒的。回到题目,如果是 1000 瓶水,只需要知道第 1000 号的二进制数 0b1111101000即可。该二进制数一共有 10 个比特位,意味着 1000 瓶水可以兑出 10 份样品,也就是说只需要 10 只老鼠,就可以完成测试任务。

尾声

关于位掩码技术的探索就到这里。相信在认真读完这篇文章以后,大家心里已经建立起对位掩码技术的概念。这是一种非常特别的问题解决思路,也许在未来的某一天你真的会用上它。

查看原文

赞 16 收藏 11 评论 1

jrainlau 发布了文章 · 2020-12-22

探索浏览器端的网络速度测试

image

在最近的工作中,有一项内容是需要知道当前用户的网络情况以采取对应的展示策略。这块内容是我之前没有研究过的,正好趁此机会了解一下。

那么我们要怎样做才能获取到用户的网络速度呢?下面是调研到的几种方法。

一、通过 window.navigator.connection API 获取网速

在 Chrome 浏览器种,我们可以使用 window.navigator.connection API 中的 downlink 属性来获取实时网速:
image

image

image

根据 MDN 文档的说法,该 downlink 属性是以每秒兆位为单位返回有效带宽估计值。比如当 downlink 的值是 5.0 的时候,理论下载速度可能是 5Mb/s。

遗憾的是,这个 API 仍然处于实验阶段,部分功能在 iOS 或者 Safari 上都是不可用的,我们需要找到另外一种方式去测试网速。

image

二、部署服务,通过请求接口来获取网速

这也是一些测速网站常用的做法,比如这个爱测速

image

它请求了部署在其服务器上的几个接口

  • /cesu/index/garbage
  • /cesu/index/empty
  • /cesu/index/ip

来分别获得如下载速度、上传速度、网络延迟和网络抖动等数据。

我们可以通过阅读它的测速代码 speedtest_worker.min.js 来一探究竟。简单地来说,就是通过构造不同的 XHR 对象对接口进行请求,记录开始和结束的时间,最后换算成具体的数据。

这种部署服务的方式比较主流,但是实现成本较高。另外还有一点需要注意,如果接口挂了,可能就会走到 error 的逻辑,会误判为用户的网络中断了。

三、获取静态图片资源

这是一种成本较低,也是非常通用的做法。我们可以让浏览器通过 GET 请求去获取一张既定大小的图片,分析获取图片所需的时间,即可换算成实际的下载速度。这种办法比第一小节的 window.navigator.connection.downlink 数据更准确,因为那个 API 的只是理论值,实际值还需要通过真正地去下载资源才能获取。

附上实现的代码,可以看出这种方案确实最为经济实惠:

function testDownloadSpeed ({ url, size }) {
  return new Promise((resolve, reject) => {
    const img = new Image()
    img.src = `url?_t=${Math.random()}` // 加个时间戳以避免浏览器只发起一次请求
    const startTime = new Date()

    img.onload = function () {
      const fileSize = size // 单位是 kb
      const endTime = new Date()
      const costTime = endTime - startTime
      const speed = fileSize / (endTime - startTime) * 1000 // 单位是 kb/s
      resolve({ speed, costTime })
    }

    img.onerror = reject
  })
}

我们在浏览器中拿这张大小为 146kb 的图片 https://raw.githubusercontent.com/jrainlau/imghost/master/29e9103b4a6801aa42f8f08ef65ad51c%20(1).ytuxq1kbb4f.png 试一下:

image

输出的耗时和 Network 面板的基本吻合,且计算出来的下载速度也和上一节在 爱测速 得到的数据基本一致,因此可以得出这种方法靠谱的结论。

(完)

查看原文

赞 4 收藏 3 评论 0

jrainlau 赞了文章 · 2020-11-10

编译原理实战入门:用 JavaScript 写一个简单的四则运算编译器(修订版)

编译器是一个程序,作用是将一门语言翻译成另一门语言

例如 babel 就是一个编译器,它将 es6 版本的 js 翻译成 es5 版本的 js。从这个角度来看,将英语翻译成中文的翻译软件也属于编译器。

一般的程序,CPU 是无法直接执行的,因为 CPU 只能识别机器指令。所以要想执行一个程序,首先要将高级语言编写的程序翻译为汇编代码(Java 还多了一个步骤,将高级语言翻译成字节码),再将汇编代码翻译为机器指令,这样 CPU 才能识别并执行。

由于汇编语言和机器语言一一对应,并且汇编语言更具有可读性。所以计算机原理的教材在讲解机器指令时一般会用汇编语言来代替机器语言讲解。

本文所要写的四则运算编译器需要将 1 + 1 这样的四则运算表达式翻译成机器指令并执行。具体过程请看示例:

// CPU 无法识别
10 + 5

// 翻译成汇编语言
push 10
push 5
add

// 最后翻译为机器指令,汇编代码和机器指令一一对应
// 机器指令由 1 和 0 组成,以下指令非真实指令,只做演示用
0011101001010101
1101010011100101
0010100111100001

四则运算编译器,虽然说功能很简单,只能编译四则运算表达式。但是编译原理的前端部分几乎都有涉及:词法分析、语法分析。另外还有编译原理后端部分的代码生成。不管是简单的、复杂的编译器,编译步骤是差不多的,只是复杂的编译器实现上会更困难。

可能有人会问,学会编译原理有什么好处

我认为对编译过程内部原理的掌握将会使你成为更好的高级程序员。另外在这引用一下知乎网友-随心所往的回答,更加具体:

  1. 可以更加容易的理解在一个语言种哪些写法是等价的,哪些是有差异的
  2. 可以更加客观的比较不同语言的差异
  3. 更不容易被某个特定语言的宣扬者忽悠
  4. 学习新的语言是效率也会更高
  5. 其实从语言a转换到语言b是一个通用的需求,学好编译原理处理此类需求时会更加游刃有余

好了,下面让我们看一下如何写一个四则运算编译器。

词法分析

程序其实就是保存在文本文件中的一系列字符,词法分析的作用是将这一系列字符按照某种规则分解成一个个字元(token,也称为终结符),忽略空格和注释。

示例:

// 程序代码
10 + 5 + 6

// 词法分析后得到的 token
10
+
5
+
6

终结符

终结符就是语言中用到的基本元素,它不能再被分解。

四则运算中的终结符包括符号和整数常量(暂不支持一元操作符和浮点运算)。

  1. 符号+ - * / ( )
  2. 整数常量:12、1000、111...

词法分析代码实现

function lexicalAnalysis(expression) {
    const symbol = ['(', ')', '+', '-', '*', '/']
    const re = /\d/
    const tokens = []
    const chars = expression.trim().split('')
    let token = ''
    chars.forEach(c => {
        if (re.test(c)) {
            token += c
        } else if (c == ' ' && token) {
            tokens.push(token)
            token = ''
        } else if (symbol.includes(c)) {
            if (token) {
                tokens.push(token)
                token = ''
            } 

            tokens.push(c)
        }
    })

    if (token) {
        tokens.push(token)
    }

    return tokens
}

console.log(lexicalAnalysis('100    +   23   +    34 * 10 / 2')) 
// ["100", "+", "23", "+", "34", "*", "10", "/", "2"]

四则运算的语法规则(语法规则是分层的)

  1. x*, 表示 x 出现零次或多次
  2. x | y, 表示 x 或 y 将出现
  3. ( ) 圆括号,用于语言构词的分组

以下规则从左往右看,表示左边的表达式还能继续往下细分成右边的表达式,一直细分到不可再分为止。

  • expression: addExpression
  • addExpression: mulExpression (op mulExpression)*
  • mulExpression: term (op term)*
  • term: '(' expression ')' | integerConstant
  • op: + - * /

addExpression 对应 +- 表达式,mulExpression 对应 */ 表达式。

如果你看不太懂以上的规则,那就先放下,继续往下看。看看怎么用代码实现语法分析。

语法分析

对输入的文本按照语法规则进行分析并确定其语法结构的一种过程,称为语法分析。

一般语法分析的输出为抽象语法树(AST)或语法分析树(parse tree)。但由于四则运算比较简单,所以这里采取的方案是即时地进行代码生成和错误报告,这样就不需要在内存中保存整个程序结构。

先来看看怎么分析一个四则运算表达式 1 + 2 * 3

首先匹配的是 expression,由于目前 expression 往下分只有一种可能,即 addExpression,所以分解为 addExpression
依次类推,接下来的顺序为 mulExpressionterm1(integerConstant)、+(op)、mulExpressionterm2(integerConstant)、*(op)、mulExpressionterm3(integerConstant)。

如下图所示:

这里可能会有人有疑问,为什么一个表达式搞得这么复杂,expression 下面有 addExpressionaddExpression 下面还有 mulExpression
其实这里是为了考虑运算符优先级而设的,mulExpraddExpr 表达式运算级要高。

1 + 2 * 3
compileExpression
   | compileAddExpr
   |  | compileMultExpr
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 1
   |  |  |_
   |  | matches '+'
   |  | compileMultExpr
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 2
   |  |  | matches '*'
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 3
   |  |  |_ compileOp('*')                      *
   |  |_ compileOp('+')                         +
   |_

有很多算法可用来构建语法分析树,这里只讲两种算法。

递归下降分析法

递归下降分析法,也称为自顶向下分析法。按照语法规则一步步递归地分析 token 流,如果遇到非终结符,则继续往下分析,直到终结符为止。

LL(0)分析法

递归下降分析法是简单高效的算法,LL(0)在此基础上多了一个步骤,当第一个 token 不足以确定元素类型时,对下一个字元采取“提前查看”,有可能会解决这种不确定性。

以上是对这两种算法的简介,具体实现请看下方的代码实现。

表达式代码生成

我们通常用的四则运算表达式是中缀表达式,但是对于计算机来说中缀表达式不便于计算。所以在代码生成阶段,要将中缀表达式转换为后缀表达式。

后缀表达式

后缀表达式,又称逆波兰式,指的是不包含括号,运算符放在两个运算对象的后面,所有的计算按运算符出现的顺序,严格从左向右进行(不再考虑运算符的优先规则)。

示例:

中缀表达式: 5 + 5 转换为后缀表达式:5 5 +,然后再根据后缀表达式生成代码。

// 5 + 5 转换为 5 5 + 再生成代码
push 5
push 5
add

代码实现

编译原理的理论知识像天书,经常让人看得云里雾里,但真正动手做起来,你会发现,其实还挺简单的。

如果上面的理论知识看不太懂,没关系,先看代码实现,然后再和理论知识结合起来看。

注意:这里需要引入刚才的词法分析代码。

// 汇编代码生成器
function AssemblyWriter() {
    this.output = ''
}

AssemblyWriter.prototype = {
    writePush(digit) {
        this.output += `push ${digit}\r\n`
    },

    writeOP(op) {
        this.output += op + '\r\n'
    },

    //输出汇编代码
    outputStr() {
        return this.output
    }
}

// 语法分析器
function Parser(tokens, writer) {
    this.writer = writer
    this.tokens = tokens
    // tokens 数组索引
    this.i = -1
    this.opMap1 = {
        '+': 'add',
        '-': 'sub',
    }

    this.opMap2 = {
        '/': 'div',
        '*': 'mul'
    }

    this.init()
}

Parser.prototype = {
    init() {
        this.compileExpression()
    },

    compileExpression() {
        this.compileAddExpr()
    },

    compileAddExpr() {
        this.compileMultExpr()
        while (true) {
            this.getNextToken()
            if (this.opMap1[this.token]) {
                let op = this.opMap1[this.token]
                this.compileMultExpr()
                this.writer.writeOP(op)
            } else {
                // 没有匹配上相应的操作符 这里为没有匹配上 + - 
                // 将 token 索引后退一位
                this.i--
                break
            }
        }
    },

    compileMultExpr() {
        this.compileTerm()
        while (true) {
            this.getNextToken()
            if (this.opMap2[this.token]) {
                let op = this.opMap2[this.token]
                this.compileTerm()
                this.writer.writeOP(op)
            } else {
                // 没有匹配上相应的操作符 这里为没有匹配上 * / 
                // 将 token 索引后退一位
                this.i--
                break
            }
        }
    },

    compileTerm() {
        this.getNextToken()
        if (this.token == '(') {
            this.compileExpression()
            this.getNextToken()
            if (this.token != ')') {
                throw '缺少右括号:)'
            }
        } else if (/^\d+$/.test(this.token)) {
            this.writer.writePush(this.token)
        } else {
            throw '错误的 token:第 ' + (this.i + 1) + ' 个 token (' + this.token + ')'
        }
    },

    getNextToken() {
        this.token = this.tokens[++this.i]
    },

    getInstructions() {
        return this.writer.outputStr()
    }
}

const tokens = lexicalAnalysis('100+10*10')
const writer = new AssemblyWriter()
const parser = new Parser(tokens, writer)
const instructions = parser.getInstructions()
console.log(instructions) // 输出生成的汇编代码
/*
push 100
push 10
push 10
mul
add
*/

模拟执行

现在来模拟一下 CPU 执行机器指令的情况,由于汇编代码和机器指令一一对应,所以我们可以创建一个直接执行汇编代码的模拟器。
在创建模拟器前,先来讲解一下相关指令的操作。

在内存中,栈的特点是只能在同一端进行插入和删除的操作,即只有 push 和 pop 两种操作。

push

push 指令的作用是将一个操作数推入栈中。

pop

pop 指令的作用是将一个操作数弹出栈。

add

add 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,然后执行 a + b,再将结果 push 到栈中。

sub

sub 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,然后执行 a - b,再将结果 push 到栈中。

mul

mul 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,然后执行 a * b,再将结果 push 到栈中。

div

sub 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,然后执行 a / b,再将结果 push 到栈中。

四则运算的所有指令已经讲解完毕了,是不是觉得很简单?

代码实现

注意:需要引入词法分析和语法分析的代码

function CpuEmulator(instructions) {
    this.ins = instructions.split('\r\n')
    this.memory = []
    this.re = /^(push)\s\w+/
    this.execute()
}

CpuEmulator.prototype = {
    execute() {
        this.ins.forEach(i => {
            switch (i) {
                case 'add':
                    this.add()
                    break
                case 'sub':
                    this.sub()
                    break
                case 'mul':
                    this.mul()
                    break
                case 'div':
                    this.div()
                    break                
                default:
                    if (this.re.test(i)) {
                        this.push(i.split(' ')[1])
                    }
            }
        })
    },

    add() {
        const b = this.pop()
        const a = this.pop()
        this.memory.push(a + b)
    },

    sub() {
        const b = this.pop()
        const a = this.pop()
        this.memory.push(a - b)
    },

    mul() {
        const b = this.pop()
        const a = this.pop()
        this.memory.push(a * b)
    },

    div() {
        const b = this.pop()
        const a = this.pop()
        // 不支持浮点运算,所以在这要取整
        this.memory.push(Math.floor(a / b))
    },

    push(x) {
        this.memory.push(parseInt(x))
    },

    pop() {
        return this.memory.pop()
    },

    getResult() {
        return this.memory[0]
    }
}

const tokens = lexicalAnalysis('(100+  10)*  10-100/  10      +8*  (4+2)')
const writer = new AssemblyWriter()
const parser = new Parser(tokens, writer)
const instructions = parser.getInstructions()
const emulator = new CpuEmulator(instructions)
console.log(emulator.getResult()) // 1138

一个简单的四则运算编译器已经实现了。我们再来写一个测试函数跑一跑,看看运行结果是否和我们期待的一样:

function assert(expression, result) {
    const tokens = lexicalAnalysis(expression)
    const writer = new AssemblyWriter()
    const parser = new Parser(tokens, writer)
    const instructions = parser.getInstructions()
    const emulator = new CpuEmulator(instructions)
    return emulator.getResult() == result
}

console.log(assert('1 + 2 + 3', 6)) // true
console.log(assert('1 + 2 * 3', 7)) // true
console.log(assert('10 / 2 * 3', 15)) // true
console.log(assert('(10 + 10) / 2', 10)) // true

测试全部正确。另外附上完整的源码,建议没看懂的同学再看多两遍。

更上一层楼

对于工业级编译器来说,这个四则运算编译器属于玩具中的玩具。但是人不可能一口吃成个胖子,所以学习编译原理最好采取循序渐进的方式去学习。下面来介绍一个高级一点的编译器,这个编译器可以编译一个 Jack 语言(类 Java 语言),它的语法大概是这样的:

class Generate {
    field String str;
    static String str1;
    constructor Generate new(String s) {
        let str = s;
        return this;
    }

    method String getString() {
        return str;
    }
}

class Main {
    function void main() {
        var Generate str;
        let str = Generate.new("this is a test");
        do Output.printString(str.getString());
        return;
    }
}

上面代码的输出结果为:this is a test

想不想实现这样的一个编译器?

这个编译器出自一本书《计算机系统要素》,它从第 6 章开始,一直到第 11 章讲解了汇编编译器(将汇编语言转换为机器语言)、VM 编译器(将类似于字节码的 VM 语言翻译成汇编语言)、Jack 语言编译器(将高级语言 Jack 翻译成 VM 语言)。每一章都有详细的知识点讲解和实验,只要你一步一步跟着做实验,就能最终实现这样的一个编译器。

如果编译器写完了,最后机器语言在哪执行呢?

这本书已经为你考虑好了,它从第 1 章到第 5 章,一共五章的内容。教你从逻辑门开始,逐步组建出算术逻辑单元 ALU、CPU、内存,最终搭建出一个现代计算机。然后让你用编译器编译出来的程序运行在这台计算机之上。

另外,这本书的第 12 章会教你写操作系统的各种库函数,例如 Math 库(包含各种数学运算)、Keyboard 库(按下键盘是怎么输出到屏幕上的)、内存管理等等。

想看一看全书共 12 章的实验做完之后是怎么样的吗?我这里提供几张这台模拟计算机运行程序的 DEMO GIF,供大家参考参考。

这几张图中的右上角是“计算机”的屏幕,其他部分是“计算机”的堆栈区和指令区。

这本书的所有实验我都已经做完了(每天花 3 小时,两个月就能做完),答案放在我的 github 上,有兴趣的话可以看看。

参考资料

我的博客即将同步至 OSCHINA 社区,这是我的 OSCHINA ID:谭光志,邀请大家一同入驻:https://www.oschina.net/shari...

查看原文

赞 15 收藏 10 评论 2

jrainlau 发布了文章 · 2020-10-22

Redis + NodeJS 实现一个能处理海量数据的异步任务队列系统

未命名 002

项目仓库:https://github.com/jrainlau/n...

前言

在最近的业务中,接到了一个需要处理约十万条数据的需求。这些数据都以字符串的形式给到,并且处理它们的步骤是异步且耗时的(平均处理一条数据需要 25s 的时间)。如果以串行的方式实现,其耗时是相当长的:

总耗时时间 = 数据量 × 单条数据处理时间

T = N * t (N = 100,000; t = 25s)

总耗时时间 = 2,500,000 秒 ≈ 695 小时 ≈ 29 天

显然,我们不能简单地把数据一条一条地处理。那么有没有办法能够减少处理的时间呢?经过调研后发现,使用异步任务队列是个不错的办法。

一、异步任务队列原理

我们可以把“处理单条数据”理解为一个异步任务,因此对这十万条数据的处理,就可以转化成有十万个异步任务等待进行。我们可以把这十万条数据塞到一个队列里面,让任务处理器自发地从队列里面去取得并完成。

任务处理器可以有多个,它们同时从队列里面把任务取走并处理。当任务队列为空,表示所有任务已经被认领完;当所有任务处理器完成任务,则表示所有任务已经被处理完。

其基本原理如下图所示:

未命名 001

首先来解决任务队列的问题。在这个需求中,任务队列里面的每一个任务,都包含了待处理的数据,数据以字符串的形式存在。为了方便起见,我们可以使用 RedisList 数据格式来存放这些任务。

由于项目是基于 NodeJS 的,我们可以利用 PM2Cluster 模式来启动多个任务处理器,并行地处理任务。以一个 8 核的 CPU 为例,如果完全开启了多进程,其理论处理时间将提升 8 倍,从 29 天缩短到 3.6 天。

接下来,我们会从实际编码的角度来讲解上述内容的实现过程。

二、使用 NodeJS 操作 Redis

异步任务队列使用 Redis 来实现,因此我们需要部署一个单独的 Redis 服务。在本地开发中为了快速完成 Redis 的安装,我使用了 Docker 的办法(默认机器已经安装了 Docker)。

  1. Docker 拉取 Redis 镜像
docker pull redis:latest
  1. Docker 启动 Redis
docker run -itd --name redis-local -p 6379:6379 redis

此时我们已经使用 Docker 启动了一个 Redis 服务,其对外的 IP 及端口为 127.0.0.1:6379。此外,我们还可以在本地安装一个名为 Another Redis DeskTop Manager
的 Redis 可视化工具,来实时查看、修改 Redis 的内容。

image

在 NodeJS 中,我们可以使用 node-redis 来操作 Redis。新建一个 mqclient.ts 文件并写入如下内容:

import * as Redis from 'redis'

const client = Redis.createClient({
  host: '127.0.0.1',
  port: 6379
})

export default client

Redis 本质上是一个数据库,而我们对数据库的操作无非就是增删改查。node-redis 支持 Redis 的所有交互操作方式,但是操作结果默认是以回调函数的形式返回。为了能够使用 async/await,我们可以新建一个 utils.ts 文件,把 node-redis 操作 Redis 的各种操作都封装成 Promise 的形式,方便我们后续使用。

import client from './mqClient'

// 获取 Redis 中某个 key 的内容
export const getRedisValue = (key: string): Promise<string | null> => new Promise(resolve => client.get(key, (err, reply) => resolve(reply)))
// 设置 Redis 中某个 key 的内容
export const setRedisValue = (key: string, value: string) => new Promise(resolve => client.set(key, value, resolve))
// 删除 Redis 中某个 key 及其内容
export const delRedisKey = (key: string) => new Promise(resolve => client.del(key, resolve))

除此之外,还能在 utils.ts 中放置其他常用的工具方法,以实现代码的复用、保证代码的整洁。

为了在 Redis 中创建任务队列,我们可以单独写一个 createTasks.ts 的脚本,用于往队列中塞入自定义的任务。

import { TASK_NAME, TASK_AMOUNT, setRedisValue, delRedisKey } from './utils'
import client from './mqClient'

client.on('ready', async () => {
  await delRedisKey(TASK_NAME)
  for (let i = TASK_AMOUNT; i > 0 ; i--) {
    client.lpush(TASK_NAME, `task-${i}`)
  }
  
  client.lrange(TASK_NAME, 0, TASK_AMOUNT, async (err, reply) => {
    if (err) {
      console.error(err)
      return
    }
    console.log(reply)
    process.exit()
  })
})

在这段脚本中,我们从 utils.ts 中获取了各个 Redis 操作的方法,以及任务的名称 TASK_NAME (此处为 local_tasks)和任务的总数 TASK_AMOUNT(此处为 20 个)。通过 LPUSH 方法往 TASK_NAME 的 List 当中塞入内容为 task-1task-20 的任务,如图所示:

image

image

三、异步任务处理

首先新建一个 index.ts 文件,作为整个异步任务队列处理系统的入口文件。

import taskHandler from './tasksHandler'
import client from './mqClient'

client.on('connect', () => {
  console.log('Redis is connected!')
})
client.on('ready', async () => {
  console.log('Redis is ready!')
  await taskHandler()
})
client.on('error', (e) => {
  console.log('Redis error! ' + e)
})

在运行该文件时,会自动连接 Redis,并且在 ready 状态时执行任务处理器 taskHandler()

在上一节的操作中,我们往任务队列里面添加了 20 个任务,每个任务都是形如 task-n 的字符串。为了验证异步任务的实现,我们可以在任务处理器 taskHandler.ts 中写一段 demo 函数,来模拟真正的异步任务:

  function handleTask(task: string) {
    return new Promise((resolve) => {
      setTimeout(async () => {
        console.log(`Handling task: ${task}...`)
        resolve()
      }, 2000)
    })
  }

上面这个 handleTask() 函数,将会在执行的 2 秒后打印出当前任务的内容,并返回一个 Promise,很好地模拟了异步函数的实现方式。接下来我们将会围绕这个函数,来处理队列中的任务。

其实到了这一步为止,整个异步任务队列处理系统已经基本完成了,只需要在 taskHandler.ts 中补充一点点代码即可:

import { popTask } from './utils'
import client from './mqClient'

function handleTask(task: string) { /* ... */}

export default async function tasksHandler() {
  // 从队列中取出一个任务
  const task = await popTask()
  // 处理任务
  await handleTask(task)
  // 递归运行
  await tasksHandler()
}

最后,我们使用 PM2 启动 4 个进程,来试着跑一下整个项目:

pm2 start ./dist/index.js -i 4 && pm2 logs

image

image

可以看到,4 个任务处理器分别处理完了队列中的所有任务,相互之前互不影响。

事到如今已经大功告成了吗?未必。为了测试我们的这套系统到底提升了多少的效率,还需要统计完成队列里面所有任务的总耗时。

四、统计任务完成耗时

要统计任务完成的耗时,只需要实现下列的公式即可:

总耗时 = 最后一个任务的完成时间 - 首个任务被取得的时间

首先来解决“获取首个任务被取得的时间”这个问题。

由于我们是通过 PM2 的 Cluster 模式来启动应用的,且从 Redis 队列中读取任务是个异步操作,因此在多进程运行的情况下无法直接保证从队列中读取任务的先后顺序,必须通过一个额外的标记来判断。其原理如下图:

未命名 003

如图所示,绿色的 worker 由于无法保证运行的先后顺序,所以编号用问号来表示。当第一个任务被取得时,把黄色的标记值从 false 设置成 true。当且仅当黄色的标记值为 false 时才会设置时间。这样一来,当其他任务被取得时,由于黄色的标记值已经是 true 了,因此无法设置时间,所以我们便能得到首个任务被取得的时间。

在本文的例子中,黄色的标记值和首个任务被取得的时间也被存放在 Redis 中,分别被命名为 local_tasks_SET_FIRSTlocal_tasks_BEGIN_TIME

原理已经弄懂,但是在实践中还有一个地方值得注意。我们知道,从 Redis 中读写数据也是一个异步操作。由于我们有多个 worker 但只有一个 Redis,那么在读取黄色标记值的时候很可能会出现“冲突”的问题。举个例子,当 worker-1 修改标记值为 true 的同时, worker-2 正好在读取标记值。由于时间的关系,可能 worker-2 读到的标记值依然是 false,那么这就冲突了。为了解决这个问题,我们可以使用 node-redlock 这个工具来实现“锁”的操作。

顾名思义,“锁”的操作可以理解为当 worker-1 读取并修改标记值的时候,不允许其他 worker 读取该值,也就是把标记值给锁住了。当 worker-1 完成标记值的修改时会释放锁,此时才允许其他的 worker 去读取该标记值。

node-redlock 是 Redis 分布式锁 Redlock 算法的 JavaScript 实现,关于该算法的讲解可参考 https://redis.io/topics/distlock

值得注意的是,在 node-redlock 在使用的过程中,如果要锁一个已存在的 key,就必须为该 key 添加一个前缀 locks:,否则会报错。

回到 utils.ts,编写一个 setBeginTime() 的工具函数:

export const setBeginTime = async (redlock: Redlock) => {
  // 读取标记值前先把它锁住
  const lock = await redlock.lock(`lock:${TASK_NAME}_SET_FIRST`, 1000)
  const setFirst = await getRedisValue(`${TASK_NAME}_SET_FIRST`)
   // 当且仅当标记值不等于 true 时,才设置起始时间
  if (setFirst !== 'true') {
    console.log(`${pm2tips} Get the first task!`)
    await setRedisValue(`${TASK_NAME}_SET_FIRST`, 'true')
    await setRedisValue(`${TASK_NAME}_BEGIN_TIME`, `${new Date().getTime()}`)
  }
  // 完成标记值的读写操作后,释放锁
  await lock.unlock().catch(e => e)
}

然后把它添加到 taskHandler() 函数里面即可:

export default async function tasksHandler() {
+  // 获取第一个任务被取得的时间
+  await setBeginTime(redlock)
  // 从队列中取出一个任务
  const task = await popTask()
  // 处理任务
  await handleTask(task)
  // 递归运行
  await tasksHandler()
}

接下来解决“最后一个任务的完成时间”这个问题。

类似上一个问题,由于任务执行的先后顺序无法保证,异步操作的完成时间也无法保证,因此我们也需要一个额外的标识来记录任务的完成情况。在 Redis 中创建一个初始值为 0 的标识 local_tasks_CUR_INDEX,当 worker 完成一个任务就让标识加。由于任务队列的初始长度是已知的(为 TASK_AMOUNT 常量,也写入了 Redis 的 local_tasks_TOTAL 中),因此当标识的值等于队列初始长度的值时,即可表明所有任务都已经完成。

未命名 004

如图所示,被完成的任务都会让黄色的标识加一,任何时候只要判断到标识的值等于队列的初始长度值,即可表明任务已经全部完成。

回到 taskHandler() 函数,加入下列内容:

export default async function tasksHandler() {
+  // 获取标识值和队列初始长度
+  let curIndex = Number(await getRedisValue(`${TASK_NAME}_CUR_INDEX`))
+  const taskAmount = Number(await getRedisValue(`${TASK_NAME}_TOTAL`))
+  // 等待新任务
+  if (taskAmount === 0) {
+    console.log(`${pm2tips} Wating new tasks...`)
+    await sleep(2000)
+    await tasksHandler()
+    return
+  }
+  // 判断所有任务已经完成
+  if (curIndex === taskAmount) {
+    const beginTime = await getRedisValue(`${TASK_NAME}_BEGIN_TIME`)
+    // 获取总耗时
+    const cost = new Date().getTime() - Number(beginTime)
+    console.log(`${pm2tips} All tasks were completed! Time cost: ${cost}ms. ${beginTime}`)
+    // 初始化 Redis 的一些标识值
+    await setRedisValue(`${TASK_NAME}_TOTAL`, '0') 
+    await setRedisValue(`${TASK_NAME}_CUR_INDEX`, '0')
+    await setRedisValue(`${TASK_NAME}_SET_FIRST`, 'false')
+    await delRedisKey(`${TASK_NAME}_BEGIN_TIME`)
+    await sleep(2000)
+    await tasksHandler()
  }
  // 获取第一个任务被取得的时间
  await setBeginTime(redlock)
  // 从队列中取出一个任务
  const task = await popTask()
  // 处理任务
  await handleTask(task)
+ // 任务完成后需要为标识位加一
+  try {
+    const lock = await redlock.lock(`lock:${TASK_NAME}_CUR_INDEX`, 1000)
+    curIndex = await getCurIndex()
+    await setCurIndex(curIndex + 1)
+    await lock.unlock().catch((e) => e)
+  } catch (e) {
+    console.log(e)
+  }
+  // recursion
+  await tasksHandler()
+}
  // 递归运行
  await tasksHandler()
}

到这一步为止,我们已经解决了获取“最后一个任务的完成时间”的问题,再结合前面的首个任务被取得的时间,便能得出运行的总耗时。


最后来看一下实际的运行效果。我们循例往队列里面添加了 task-1task-20 这 20 个任务,然后启动 4 个进程来跑:

image

image

运行状况良好。从运行结果来看,4 个进程处理 20 个平均耗时 2 秒的任务,只需要 10 秒的时间,完全符合设想。

五、小结

当面对海量的异步任务需要处理的时候,多进程 + 任务队列的方式是一个不错的解决方式。本文通过探索 Redis + NodeJS 结合的方式,构造出了一个异步任务队列处理系统,能较好地完成最初方案的设想,但依然有很多问题需要改进。比如说当任务出错了应该怎么办,系统能否支持不同类型的任务,能否运行多个队列等等,都是值得思考的问题。如果读者朋友有更好的想法,欢迎留言和我交流!

(完)

查看原文

赞 21 收藏 13 评论 5

jrainlau 赞了文章 · 2020-09-27

聊聊 ESM、Bundle 、Bundleless 、Vite 、Snowpack

前言

一切要都要从打包构建说起。

当下我们很多项目都是基于 webpack 构建的, 主要用于:

  • 本地开发
  • 打包上线

首先,webpack 是一个伟大的工具。

经过不断的完善,webpack 以及周边的各种轮子已经能很好的满足我们的日常开发需求。

我们都知道,webpack 具备将各类资源打包整合在一起,形成 bundle 的能力。

可是,当资源越来越多时,打包的时间也将越来越长。

一个中大型的项目, 启动构建的时间能达到数分钟之久。

拿我的项目为例, 初次构建大概需要三分钟, 而且这个时间会随着系统的迭代越来越长。

相信不少同学也都遇到过类似的问题。 打包时间太久,这是一个让人很难受的事情。

那有没有什么办法来解决呢?

当然是有的。

这就是今天的主角 ESM, 以及以它为基础的各类构建工具, 比如:

  1. Snowpack
  2. Vite
  3. Parcel

等等。

今天,我们就这个话题展开讨论, 希望能给大家一些其发和帮助。

文章较长,提供一个传送门:

  1. 什么是 ESM
  2. ESM 是如何工作的
  3. Bundle & Bundleless
  4. 实现一个乞丐版 Vite
  5. Snowpack & 实践
  6. bundleless 模式在实际开发中存在的一些问题
  7. 结论

正文

什么是 ESM

ESM 是理论基础, 我们都需要了解。

「 ESM 」 全称 ECMAScript modules,基本主流的浏览器版本都以已经支持。

image.png

ESM 是如何工作的

image.png

当使用ESM 模式时, 浏览器会构建一个依赖关系图。不同依赖项之间的连接来自你使用的导入语句。

通过这些导入语句, 浏览器 或 Node 就能确定加载代码的方式。

通过指定一个入口文件,然后从这个文件开始,通过其中的import语句,查找其他代码。

image.png

通过指定的文件路径, 浏览器就找到了目标代码文件。 但是浏览器并不能直接使用这些文件,它需要解析所有这些文件,以将它们转换为称为模块记录的数据结构。

image.png

然后,需要将 模块记录 转换为 模块实例

image.png

模块实例, 实际上是 「 代码 」(指令列表)与「 状态」(所有变量的值)的组合。

对于整个系统而言, 我们需要的是每个模块的模块实例。

模块加载的过程将从入口文件变为具有完整的模块实例图。

对于ES模块,这分为 三个步骤

  1. 构造—查找,下载所有文件并将其解析为模块记录。
  2. 实例化—查找内存中的框以放置所有导出的值(但尚未用值填充它们)。然后使导出和导入都指向内存中的那些框,这称为链接。
  3. 运行—运行代码以将变量的实际值填充到框中。

image.png

在构建阶段时, 发生三件事情:

  1. 找出从何处下载包含模块的文件
  2. 提取文件(通过从URL下载文件或从文件系统加载文件)
  3. 将文件解析为模块记录

1. 查找

首先,需要找到入口点文件。

在HTML中,可以通过脚本标记告诉加载程序在哪里找到它。

image.png

但是,如何找到下一组模块, 也就是 main.js 直接依赖的模块呢?

这就是导入语句的来源。

导入语句的一部分称为模块说明符, 它告诉加载程序可以在哪里找到每个下一个模块。

image.png

在解析文件之前,我们不知道模块需要获取哪些依赖项,并且在提取文件之前,也无法解析文件。

这意味着我们必须逐层遍历树,解析一个文件,然后找出其依赖项,然后查找并加载这些依赖项。

image.png

如果主线程要等待这些文件中的每个文件下载,则许多其他任务将堆积在其队列中。

那是因为当浏览器中工作时,下载部分会花费很长时间。

image.png

这样阻塞主线程会使使用模块的应用程序使用起来太慢。

这是ES模块规范将算法分为多个阶段的原因之一。

将构造分为自己的阶段,使浏览器可以在开始实例化的同步工作之前获取文件并建立对模块图的理解。

这种方法(算法分为多个阶段)是 ESMCommonJS模块 之间的主要区别之一。

CommonJS可以做不同的事情,因为从文件系统加载文件比通过Internet下载花费的时间少得多。

这意味着Node可以在加载文件时阻止主线程。

并且由于文件已经加载,因此仅实例化和求值(在CommonJS中不是单独的阶段)是有意义的。

这也意味着在返回模块实例之前,需要遍历整棵树,加载,实例化和评估任何依赖项。

该图显示了一个Node模块评估一个require语句,然后Node将同步加载和评估该模块及其任何依赖项

在具有CommonJS模块的Node中,可以在模块说明符中使用变量。

require在寻找下一个模块之前,正在执行该模块中的所有代码。这意味着当进行模块解析时,变量将具有一个值。

但是,使用ES模块时,需要在进行任何评估之前预先建立整个模块图。

这意味着不能在模块说明符中包含变量,因为这些变量还没有值。

使用变量的require语句很好。 使用变量的导入语句不是。

但是,有时将变量用于模块路径确实很有用。

例如,你可能要根据代码在做什么,或者在不同环境中运行来记载不同的模块。

为了使ES模块成为可能,有一个建议叫做动态导入。有了它,您可以使用类似的导入语句:

import(`${path}/foo.js`)

这种工作方式是将使用加载的任何文件import()作为单独图的入口点进行处理。

动态导入的模块将启动一个新图,该图将被单独处理。

两个模块图之间具有依赖性,并用动态导入语句标记

但是要注意一件事–这两个图中的任何模块都将共享一个模块实例。

这是因为加载程序会缓存模块实例。对于特定全局范围内的每个模块,将只有一个模块实例。

这意味着发动机的工作量更少。

例如,这意味着即使多个模块依赖该模块文件,它也只会被提取一次。(这是缓存模块的一个原因。我们将在评估部分中看到另一个原因。)

加载程序使用称为模块映射的内容来管理此缓存。每个全局变量在单独的模块图中跟踪其模块。

当加载程序获取一个URL时,它将把该URL放入模块映射中,并记下它当前正在获取文件。然后它将发出请求并继续以开始获取下一个文件。

加载程序图填充在“模块映射表”中,主模块的URL在左侧,而“获取”一词在右侧

如果另一个模块依赖于同一文件会怎样?加载程序将在模块映射中查找每个URL。如果在其中看到fetching,它将继续前进到下一个URL。

但是模块图不仅跟踪正在获取的文件。模块映射还充当模块的缓存,如下所示。

2. 解析

现在我们已经获取了该文件,我们需要将其解析为模块记录。这有助于浏览器了解模块的不同部分。

该图显示了被解析成模块记录的main.js文件

创建模块记录后,它将被放置在模块图中。这意味着无论何时从此处请求,加载程序都可以将其从该映射中拉出。

模块映射图中的“获取”占位符被模块记录填充

解析中有一个细节看似微不足道,但实际上有很大的含义。

解析所有模块,就像它们"use strict"位于顶部一样。还存在其他细微差异。

例如,关键字await是在模块的顶级代码保留,的值this就是undefined

这种不同的解析方式称为“解析目标”。如果解析相同的文件但使用不同的目标,那么最终将得到不同的结果。
因此,需要在开始解析之前就知道要解析的文件类型是否是模块。

在浏览器中,这非常简单。只需放入type="module"的script标签。
这告诉浏览器应将此文件解析为模块。并且由于只能导入模块,因此浏览器知道任何导入也是模块。

加载程序确定main.js是一个模块,因为script标签上的type属性表明是这样,而counter.js必须是一个模块,因为它已导入

但是在Node中,您不使用HTML标记,因此无法选择使用type属性。社区尝试解决此问题的一种方法是使用 .mjs扩展。使用该扩展名告诉Node,“此文件是一个模块”。您会看到人们在谈论这是解析目标的信号。目前讨论仍在进行中,因此尚不清楚Node社区最终决定使用什么信号。

无论哪种方式,加载程序都将确定是否将文件解析为模块。如果它是一个模块并且有导入,则它将重新开始该过程,直到提取并解析了所有文件。

我们完成了!在加载过程结束时,您已经从只有入口点文件变为拥有大量模块记录。

建设阶段的结果,左侧为JS文件,右侧为3个已解析的模块记录

下一步是实例化此模块并将所有实例链接在一起。

3. 实例化

就像我之前提到的,实例将代码与状态结合在一起。

该状态存在于内存中,因此实例化步骤就是将所有事物连接到内存。

首先,JS引擎创建一个模块环境记录。这将管理模块记录的变量。然后,它将在内存中找到所有导出的框。模块环境记录将跟踪与每个导出关联的内存中的哪个框。

内存中的这些框尚无法获取其值。只有在评估之后,它们的实际值才会被填写。该规则有一个警告:在此阶段中初始化所有导出的函数声明。这使评估工作变得更加容易。

为了实例化模块图,引擎将进行深度优先的后顺序遍历。这意味着它将下降到图表的底部-底部的不依赖其他任何内容的依赖项-并设置其导出。

中间的一列空内存。 计数和显示模块的模块环境记录已连接到内存中的框。

引擎完成了模块下面所有出口的接线-模块所依赖的所有出口。然后,它返回一个级别,以连接来自该模块的导入。

请注意,导出和导入均指向内存中的同一位置。首先连接出口,可以确保所有进口都可以连接到匹配的出口。

与上图相同,但具有main.js的模块环境记录,现在其导入链接到其他两个模块的导出。

这不同于CommonJS模块。在CommonJS中,整个导出对象在导出时被复制。这意味着导出的任何值(例如数字)都是副本。

这意味着,如果导出模块以后更改了该值,则导入模块将看不到该更改。

中间的内存中,有一个导出的通用JS模块指向一个内存位置,然后将值复制到另一个内存位置,而导入的JS模块则指向新位置

相反,ES模块使用称为实时绑定的东西。两个模块都指向内存中的相同位置。这意味着,当导出模块更改值时,该更改将显示在导入模块中。

导出值的模块可以随时更改这些值,但是导入模块不能更改其导入的值。话虽如此,如果模块导入了一个对象,则它可以更改该对象上的属性值。

导出模块更改内存中的值。 导入模块也尝试但失败。

之所以拥有这样的实时绑定,是因为您可以在不运行任何代码的情况下连接所有模块。当您具有循环依赖性时,这将有助于评估,如下所述。

因此,在此步骤结束时,我们已连接了所有实例以及导出/导入变量的存储位置。

现在我们可以开始评估代码,并用它们的值填充这些内存位置。

4. 执行

最后一步是将这些框填充到内存中。JS引擎通过执行顶级代码(函数外部的代码)来实现此目的。

除了仅在内存中填充这些框外,评估代码还可能触发副作用。例如,模块可能会调用服务器。

模块将在功能之外进行编码,标记为顶级代码

由于存在潜在的副作用,您只需要评估模块一次。与实例化中发生的链接可以完全相同的结果执行多次相反,评估可以根据您执行多少次而得出不同的结果。

这是拥有模块映射的原因之一。模块映射通过规范的URL缓存模块,因此每个模块只有一个模块记录。这样可以确保每个模块仅执行一次。与实例化一样,这是深度优先的后遍历。

那我们之前谈到的那些周期呢?

在循环依赖关系中,您最终在图中有一个循环。通常,这是一个漫长的循环。但是为了解释这个问题,我将使用一个简短的循环的人为例子。

左侧为4个模块循环的复杂模块图。 右侧有一个简单的2个模块循环。

让我们看一下如何将其与CommonJS模块一起使用。首先,主模块将执行直到require语句。然后它将去加载计数器模块。

一个commonJS模块,其变量是在require语句之后从main.js导出到counter.js的,具体取决于该导入

然后,计数器模块将尝试message从导出对象进行访问。但是由于尚未在主模块中对此进行评估,因此它将返回undefined。JS引擎将在内存中为局部变量分配空间,并将其值设置为undefined。

中间的内存,main.js和内存之间没有连接,但是从counter.js到未定义的内存位置的导入链接

评估一直持续到计数器模块顶级代码的末尾。我们想看看我们是否最终将获得正确的消息值(在评估main.js之后),因此我们设置了超时时间。然后评估在上恢复main.js

counter.js将控制权返回给main.js,从而完成评估

消息变量将被初始化并添加到内存中。但是由于两者之间没有连接,因此在所需模块中它将保持未定义状态。

main.js获取到内存的导出连接并填写正确的值,但是counter.js仍指向其中未定义的其他内存位置

如果使用实时绑定处理导出,则计数器模块最终将看到正确的值。到超时运行时,main.js的评估将完成并填写值。

支持这些循环是ES模块设计背后的重要理由。正是这种设计使它们成为可能。


(以上是关于 ESM 的理论介绍, 原文链接在文末)。

Bundle & Bundleless

谈及 Bundleless 的优势,首先是启动快

因为不需要过多的打包,只需要处理修改后的单个文件,所以响应速度是 O(1) 级别,刷新即可即时生效,速度很快。

image.png

所以, 在开发模式下,相比于Bundle,Bundleless 有着巨大的优势。

基于 Webpack 的 bundle 开发模式

image.png
上面的图具体的模块加载机制可以简化为下图:
image.png
在项目启动和有文件变化时重新进行打包,这使得项目的启动和二次构建都需要做较多的事情,相应的耗时也会增长。

基于 ESModule 的 Bundleless 模式

image.png
从上图可以看到,已经不再有一个构建好的 bundle、chunk 之类的文件,而是直接加载本地对应的文件。
image.png
从上图可以看到,在 Bundleless 的机制下,项目的启动只需要启动一个服务器承接浏览器的请求即可,同时在文件变更时,也只需要额外处理变更的文件即可,其他文件可直接在缓存中读取。

对比总结

image.png

Bundleless 模式可以充分利用浏览器自主加载的特性,跳过打包的过程,使得我们能在项目启动时获取到极快的启动速度,在本地更新时只需要重新编译单个文件。

实现一个乞丐版 Vite

Vite 也是基于 ESM 的, 文件处理速度 O(1)级别, 非常快。

作为探索, 我就简单实现了一个乞丐版Vite:

GitHub 地址: Vite-mini

image.png

简要分析一下。

<body>
  <div id="app"></div>
  <script type="module" data-original="/src/main.js"></script>
</body>

html 文件中直接使用了浏览器原生的 ESM(type="module") 能力。

所有的 js 文件经过 vite 处理后,其 import 的模块路径都会被修改,在前面加上 /@modules/。当浏览器请求 import 模块的时候,vite 会在 node_modules 中找到对应的文件进行返回。

image.png

其中最关键的步骤就是模块的记载和解析, 这里我简单用koa简单实现了一下, 整体结构:

const fs = require('fs');
const path = require('path');
const Koa = require('koa');
const compilerSfc = require('@vue/compiler-sfc');
const compileDom = require('@vue/compiler-dom');
const app = new Koa();

// 处理引入路径
function rewriteImport(content) {
  // ...
}

// 处理文件类型等, 比如支持ts, less 等类似webpack的loader的功能
app.use(async (ctx) => {
  // ...
}

app.listen(3001, () => {
  console.log('3001');
});

我们先看路径相关的处理:

function rewriteImport(content) {
    return content.replace(/from ['"]([^'"]+)['"]/g, function (s0, s1) {
        // import a from './c.js' 这种格式的不需要改写
        // 只改写需要去node_module找的
        if (s1[0] !== '.' && s1[0] !== '/') {
          return `from '/@modules/${s1}'`;
        }
        return s0;
    });
}

处理文件内容: 源码地址

image.png

后续的都是类似的:

image.png

这个代码只是解释实现原理, 不同的文件类型处理逻辑其实可以抽离出去, 以中间件的形式去处理。

代码实现的比较简单, 就不额解释了。

Snowpack

image.png

和 webpack 的对比:

image.png

我使用 Snowpack 做了个 demo , 支持打包, 输出 bundle。

github: Snowpack-React-Demo

image.png

能够清晰的看到, 控制台产生了大量的文件请求(也叫瀑布网络请求),

不过因为都是加载的本地文件, 所以速度很快。

配合HMR, 实现编辑完成立刻生效, 几乎不用等待:

image.png

但是如果是在生产中,这些请求对于生产中的页面加载时间而言, 就不太好了。

尤其是HTTP1.1,浏览器都会有并行下载的上限,大部分是5个左右,所以如果你有60个依赖性要下载,就需要等好长一点。

虽然说HTTP2多少可以改善这问题,但若是东西太多,依然没办法。

关于这个项目的打包, 直接执行build:

image.png

打包完成后的文件目录,和传统的 webpack 基本一致:

image.png

在 build 目录下启动一个静态文件服务:

image.png

build 模式下,还是借助了 webpack 的打包能力:

image.png

做了资源合并:

image.png

就这点而言, 我认为未来一段时间内, 生产环境还是不可避免的要走bundle模式。

bundleless 模式在实际开发中的一些问题

开门见山吧, 开发体验不是很友好,几点比较突出的问题:

  • 部分模块没有提供 ESModule 的包。(这一点尤为致命)
  • 生态不够健全,工具链不够完善;

当然还有其他方方面面的问题, 就不一一列举。

我简单改造了一个页面, 就遇到很多奇奇怪怪的问题, 开发起来十分难受, 尽管代码的修改能立刻生效。

结论

bundleless 能在开发模式下带了很大的便利。 但就目前来说,要运用到生产的话, 还是有一段路要走的。

就目当下而言, 如果真的要用的话,可能还是 bundleless(dev) + bundle(production) 的组合。

至于未来能不能全面铺开 bundleless,我认为还是有可能的, 交给时间吧。

结尾

本文主要介绍了 esm 的原理, 以及介绍了以此为基础的Vite, Snowpack 等工具, 提供了两个可运行的 demo:

  1. Vite-mini
  2. Snowpack-React-Demo

并探索了 bundleless 在生产中的可行性。

Bundleless, 本质上是将原先 Webpack 中模块依赖解析的工作交给浏览器去执行,使得在开发过程中代码的转换变少,极大地提升了开发过程中的构建速度,同时也可以更好地利用浏览器的相关开发工具。

最后,也非常感谢 ESModule、Vite、Snowpack 等标准和工具的出现,为前端开发提效。

才疏学浅, 文中若有错误,还能各位大佬指正, 谢谢。

参考资料

  1. https://hacks.mozilla.org/201...
  2. https://developer.aliyun.com/...

如果你觉得这篇内容对你挺有启发,可以:

  1. 点个「在看」,让更多的人也能看到这篇内容。
  2. 关注公众号「前端e进阶」,掌握前端面试重难点,公众号后台回复「加群」和小伙伴们畅聊技术。

图片

查看原文

赞 53 收藏 24 评论 7

jrainlau 发布了文章 · 2020-09-23

使用 babel 全家桶模块化古老的面条代码

image

在最近的工作中,接手了一个古老的项目,其中的 JS 代码是一整坨的面条代码,约 3000 行的代码全写在一个文件里,维护起来着实让人头疼。

image

想不通为啥之前维护项目的同学能够忍受这么难以维护的代码……既然现在这个锅被我拿下了,怎么着也不能容忍如此丑陋的代码继续存在着,必须把它优化一下。

横竖看了半天,由于逻辑都揉在了一个文件里,看都看得眼花缭乱,当务之急便是把它进行模块化拆分,把这一大坨面条状代码拆分成一个个模块并抽离成文件,这样才方便后续的持续优化。

一、结构分析

说干就干,既然要拆分成模块,首先就要分析源码的结构。虽然源码内容很长很复杂,但万幸的是它还是有一个清晰的结构,简化一下,就是下面这种形式:

code

很容易看出,这是一种 ES5 时代的经典代码组织方式,在一个 IIFE 里面放一个构造函数,在构造函数的 protorype 上挂载不同的方法,以实现不同的功能。既然代码结构是清晰的,那么我们要做模块化的思路也很清晰,就是想办法把所有绑定在构造函数的 prototype 上的方法抽离出来,以模块文件的形式放置,而源码则使用 ES6 的 import 语句把模块引入进来,完成代码的模块化:

code

为了完成这个效果,我们可以借助 @babel 全家桶来构造我们的转化脚本。

二、借助 AST 分析代码

关于 AST 的相关资料一搜一大堆,在这里就不赘述了。在本文中,我们会借助 AST 去分析源码,挑选源码中需要被抽离、改造的部分,因此 AST 可以说是本文的核心。在 https://astexplorer.net/ 这个网站,我们可以贴入示例代码,在线查看它的 AST 长什么样:

image

从右侧的 AST 树中可以很清晰地看到,Demo.prototype.func = function () {} 属于 AssignmentExpression 节点,即为“赋值语句”,拥有左右两个不同的节点(leftright)。

由于一段 JS 代码里可能存在多种赋值语句,而我们只想处理形如 Demo.prototype.func = function () {} 的情况,所以我们需要继续对其左右两侧的节点进行深入分析。

首先看左侧的节点,它属于一个“MemberExpression”,其特征如下图箭头所示:

image

对于左侧的节点,只要它的 object.property.name 的值为 prototype 即可,那么对应的函数名就是该节点的 property.name

接着看右侧的节点,它属于一个“FunctionExpression”:
image

我们要做的,就是把它提取出来作为一个独立的文件。

分析完了 AST 以后,我们已经知道需要被处理的代码都有一些什么样的特征,接下来就是针对这些特征进行操作了,这时候就需要我们的 @babel 全家桶出场了!

三、处理代码

首先我们需要安装四个工具,它们分别是:

  • @babel/parser:用于把 JS 源码转化成 AST;
  • @babel/traverse:用于遍历 AST 树,获取当中的节点内容;
  • @babel/generator:把 AST 节点转化成对应的 JS 代码;
  • @babel/types:新建 AST 节点。

接下来新建一个 index.js 文件,引入上面四个工具,并设法加载我们的源码(源码为 demo/es5code.js):

const fs = require('fs')
const { resolve } = require('path')

const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const t = require('@babel/types')

const INPUT_CODE = resolve(__dirname, '../demo/es5code.js')

const code = fs.readFileSync(`${INPUT_CODE}`, 'utf-8')

接着使用 @babel/parser 获取源码的 AST:

const ast = parser.parse(code)

拿到 AST 以后,就可以使用 @babel/traverse 来遍历它的节点。从上一节的 AST 分析可以知道,我们只需要关注“AssignmentExpression”节点即可:

traverse(ast, {
  AssignmentExpression ({ node }) {
    /* ... */
  }
})

当前节点即为参数 node,我们需要分析它左右两侧的节点。只有当左侧节点的类型为“MemberExpression”且右侧节点的类型为“FunctionExpression”才需要进入下一步分析(因为形如 a = 1 之类的节点也属于 AssignmentExpression 类型,不在我们的处理范围内)。

由于 JS 中可能存在不同的 MemberExpression 节点,如 a.b.c = function () {},但我们现在只需要处理 a.prototype.func 的情况,意味着要盯着关键字 prototype。通过分析 AST 节点,我们知道这个关键字位于左侧节点的 object.property.name 属性中:

image

同时对应的函数名则藏在左侧节点的 property.name 属性中:

image

因此便可以很方便地提取出方法名

traverse(ast, {
  AssignmentExpression ({ node }) {
    const { left, right } = node
    if (left.type === 'MemberExpression' && right.type === 'FunctionExpression') {
      const { object, property } = left
      if (object.property.name === 'prototype') {
        const funcName = property.name // 提取出方法名
        console.log(funcName)
      }
    }
  }
})

可以很方便地把方法名打印出来检查:

image

现在我们已经分析完左侧节点的代码,提取出了方法名。接下来则是处理右侧节点。由于右侧代码直接就是一个 FunctionExpression 节点,因此我们要做的就是通过 @babel/generator 把该节点转化成 JS 代码,并写入文件。

此外,我们也要把原来的代码从 Demo.prototype.func = function () {} 转化成 Demo.prototype.func = func 的形式,因此右侧的节点需要从“FuncitionExpression”类型转化成“Identifier”类型,我们可以借助 @babel/types 来处理。

还有一个事情别忘了,就是我们已经把右侧节点的代码抽离成了 JS 文件,那么我们也应该在最终改造完的源文件里把它们给引入进来,形如 import func1 from './func1' 这种形式,因此可以继续使用 @babel/typesimportDeclaration() 函数来生成对应的代码。这个函数参数比较复杂,可以封装成一个函数:

function createImportDeclaration (funcName) {
  return t.importDeclaration([t.importDefaultSpecifier(t.identifier(funcName))], t.stringLiteral(`./${funcName}`))
}

只需要传入一个 funcName,就可以生成一段 import funcName from './funcName' 代码。

最终整体代码如下:

const fs = require('fs')
const { resolve } = require('path')

const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const t = require('@babel/types')

const INPUT_CODE = resolve(__dirname, '../demo/es5code.js')
const OUTPUT_FOLDER = resolve(__dirname, '../output')

const code = fs.readFileSync(`${INPUT_CODE}`, 'utf-8')
const ast = parser.parse(code)

function createFile (filename, code) {
  fs.writeFileSync(`${OUTPUT_FOLDER}/${filename}.js`, code, 'utf-8')
}

function createImportDeclaration (funcName) {
  return t.importDeclaration([t.importDefaultSpecifier(t.identifier(funcName))], t.stringLiteral(`./${funcName}`))
}

traverse(ast, {
  AssignmentExpression ({ node }) {
    const { left, right } = node
    if (left.type === 'MemberExpression' && right.type === 'FunctionExpression') {
      const { object, property } = left
      if (object.property.name === 'prototype') {    
        // 获取左侧节点的方法名
        const funcName = property.name
        // 获取右侧节点对应的 JS 代码
        const { code: funcCode } = generator(right)
        // 右侧节点改为 Identifier
        const replacedNode = t.identifier(funcName)
        node.right = replacedNode
       
        // 借助 `fs.writeFileSync()` 把右侧节点的 JS 代码写入外部文件
        createFile(funcName, 'export default ' + funcCode)

        // 在文件头部引入抽离的文件
        ast.program.body.unshift(createImportDeclaration(funcName))
      }
    }
  }
})

// 输出新的文件
createFile('es6code', generate(ast).code)

四、运行脚本

在我们的项目目录中,其结构如下:

.
├── demo
│   └── es5code.js
├── output
├── package.json
└── src
    └── index.js

运行脚本,demo/es5code.js 的代码将会被处理,然后输出到 output 目录:

.
├── demo
│   └── es5code.js
├── output
│   ├── es6code.js
│   ├── func1.js
│   ├── func2.js
│   └── func3.js
├── package.json
└── src
    └── index.js

看看我们的代码:
image

image

大功告成!把脚本运用到我们的项目中,甚至可以发现原来的约 3000 行代码,已经被整理成了 300 多行:

image

放到真实环境去跑一遍这段代码,原有功能不受影响!

小结

刚刚接手这个项目,我的内心是一万头神兽奔腾而过,内心是非常崩溃的。但是既然接手了,就值得好好对待它。借助 AST 和 @babel 全家桶,我们就有了充分改造源码的手段。花半个小时个脚本,把丑陋的面条代码整理成清晰的模块化代码,内心的阴霾一扫而空,对这个古老的项目更是充满了期待——会不会有更多的地方可以被改造被优化呢?值得拭目以待!

查看原文

赞 13 收藏 7 评论 4

jrainlau 发布了文章 · 2020-07-29

【译】如何用 JavaScript 来解析 URL

原文链接:https://dmitripavlutin.com/pa...

统一资源定位符,缩写为URL,是对网络资源(网页、图像、文件)的引用。URL指定资源位置和检索资源的机制(http、ftp、mailto)。

举个例子,这里是这篇文章的 URL 地址:

https://dmitripavlutin.com/parse-url-javascript

很多时候你需要获取到一段 URL 的某个组成部分。它们可能是 hostname(例如 dmitripavlutin.com),或者 pathname(例如 /parse-url-javascript)。

一个方便的用于获取 URL 组成部分的办法是通过 URL() 构造函数。

在这篇文章中,我将给大家展示一段 URL 的结构,以及它的主要组成部分。

接着,我会告诉你如何使用 URL() 构造函数来轻松获取 URL 的组成部分,比如 hostname,pathname,query 或者 hash。

1. URL 结构

一图胜千言。不需要过多的文字描述,通过下面的图片你就可以理解一段 URL 的各个组成部分:

image

2. URL() 构造函数

URL() 构造函数允许我们用它来解析一段 URL:

const url = new URL(relativeOrAbsolute [, absoluteBase]);

参数 relativeOrAbsolute 既可以是绝对路径,也可以是相对路径。如果第一个参数是相对路径的话,那么第二个参数 absoluteBase 则必传,且必须为第一个参数的绝对路径。

举个例子,让我们用一个绝对路径的 URL 来初始化 URL() 函数:

const url = new URL('http://example.com/path/index.html');

url.href; // => 'http://example.com/path/index.html'

或者我们可以使用相对路径和绝对路径:

const url = new URL('/path/index.html', 'http://example.com');

url.href; // => 'http://example.com/path/index.html'

URL() 实例中的 href 属性返回了完整的 URL 字符串。

在新建了 URL() 的实例以后,你可以用它来访问前文图片中的任意 URL 组成部分。作为参考,下面是 URL() 实例的接口列表:

interface URL {
  href:     USVString;
  protocol: USVString;
  username: USVString;
  password: USVString;
  host:     USVString;
  hostname: USVString;
  port:     USVString;
  pathname: USVString;
  search:   USVString;
  hash:     USVString;

  readonly origin: USVString;
  readonly searchParams: URLSearchParams;

  toJSON(): USVString;
}

上述的 USVString 参数在 JavaScript 中会映射成字符串。

3. Query 字符串

url.search 可以获取到 URL 当中 ? 后面的 query 字符串:

const url = new URL(
  'http://example.com/path/index.html?message=hello&who=world'
);

url.search; // => '?message=hello&who=world'

如果 query 参数不存在,url.search 默认会返回一个空字符串 ''

const url1 = new URL('http://example.com/path/index.html');
const url2 = new URL('http://example.com/path/index.html?');

url1.search; // => ''
url2.search; // => ''

3.1 解析 query 字符串

相比于获得原生的 query 字符串,更实用的场景是获取到具体的 query 参数。

获取具体 query 参数的一个简单的方法是利用 url.searchParams 属性。这个属性是 URLSearchParams 的实例。

URLSearchParams 对象提供了许多用于获取 query 参数的方法,如get(param)has(param)等。

下面来看个例子:

const url = new URL(
  'http://example.com/path/index.html?message=hello&who=world'
);

url.searchParams.get('message'); // => 'hello'
url.searchParams.get('missing'); // => null

url.searchParams.get('message') 返回了 message 这个 query 参数的值——hello

如果使用 url.searchParams.get('missing') 来获取一个不存在的参数,则得到一个 null

4. hostname

url.hostname 属性返回一段 URL 的 hostname 部分:

const url = new URL('http://example.com/path/index.html');

url.hostname; // => 'example.com'

5. pathname

url. pathname 属性返回一段 URL 的 pathname 部分:

const url = new URL('http://example.com/path/index.html?param=value');

url.pathname; // => '/path/index.html'

如果这段 URL 不含 path,则该属性返回一个斜杠 /

const url = new URL('http://example.com/');

url.pathname; // => '/'

6. hash

最后,我们可以通过 url.hash 属性来获取 URL 中的 hash 值:

const url = new URL('http://example.com/path/index.html#bottom');

url.hash; // => '#bottom'

当 URL 中的 hash 不存在时,url.hash 属性会返回一个空字符串 ''

const url = new URL('http://example.com/path/index.html');

url.hash; // => ''

7. URL 校验

当使用 new URL() 构造函数来新建实例的时候,作为一种副作用,它同时也会对 URL 进行校验。如果 URL 不合法,则会抛出一个 TypeError

举个例子,http ://example.com 是一段非法 URL,因为它在 http 后面多写了一个空格。

让我们用这个非法 URL 来初始化 URL() 构造函数:

try {
  const url = new URL('http ://example.com');
} catch (error) {
  error; // => TypeError, "Failed to construct URL: Invalid URL"
}

因为 http ://example.com 是一段非法 URL,跟我们想的一样,new URL() 抛出了一个 TypeError

8. 修改 URL

除了获取 URL 的组成部分以外,像 searchhostnamepathnamehash 这些属性都是可写的——这也意味着你可以修改 URL。

举个例子,让我们把一段 URL 从 red.com 修改成 blue.io

const url = new URL('http://red.com/path/index.html');

url.href; // => 'http://red.com/path/index.html'

url.hostname = 'blue.io';

url.href; // => 'http://blue.io/path/index.html'

注意,在 URL() 实例中只有 originsearchParams 属性是只读的,其他所有的属性都是可写的,并且会修改原来的 URL。

9. 总结

URL() 构造函数是 JavaScript 中的一个能够很方便地用于解析(或者校验)URL 的工具。

new URL(relativeOrAbsolute [, absoluteBase]) 中的第一个参数接收 URL 的绝对路径或者相对路径。当第一个参数是相对路径时,第二个参数必传且必须为第一个参数的基路径。

在新建 URL() 的实例以后,你就能很轻易地获得 URL 当中的大部分组成部分了,比如:

  • url.search 获取原生的 query 字符串
  • url.searchParams 通过 URLSearchParams 的实例去获取具体的 query 参数
  • url.hostname获取 hostname
  • url.pathname 获取 pathname
  • url.hash 获取 hash 值

那么你最爱用的解析 URL 的 JavaScript 工具又是什么呢?

查看原文

赞 7 收藏 6 评论 2

jrainlau 发布了文章 · 2020-01-14

【译】用Node.js编写内存效率高的应用程序

一座被设计为能避开气流的建筑 (https://pixelz.cc)

软件应用程序在计算机的主存储器中运行,我们称之为随机存取存储器(RAM)。JavaScript,尤其是 NodeJS (服务端 JS)允许我们为终端用户编写从小型到大型的软件项目。处理程序的内存总是一个棘手的问题,因为糟糕的实现可能会阻塞在给定服务器或系统上运行的所有其他应用程序。C 和 C++ 程序员确实关心内存管理,因为隐藏在代码的每个角落都有可能出现可怕的内存泄漏。但是对于 JS 开发者来说,你真的有关心过这个问题吗?

由于 JS 开发人员通常在专用的高容量服务器上进行 web 服务器编程,他们可能不会察觉多任务处理的延迟。比方说在开发 web 服务器的情况下,我们也会运行多个应用程序,如数据库服务器( MySQL )、缓存服务器( Redis )和其他需要的应用。我们需要知道它们也会消耗可用的主内存。如果我们随意地编写应用程序,很可能会降低其他进程的性能,甚至让内存完全拒绝对它们的分配。在本文中,我们通过解决一个问题来了解 NodeJS 的流、缓冲区和管道等结构,并了解它们分别如何支持编写内存有效的应用程序。

我们使用 NodeJS v8.12.0 来运行这些程序,所有代码示例都放在这里:

narenaryan/node-backpressure-internals

原文链接:Writing memory efficient software applications in Node.js

问题:大文件复制

如果有人被要求用 NodeJS 写一段文件复制的程序,那么他会迅速写出下面这段代码:

const fs = require('fs');

let fileName = process.argv[2];
let destPath = process.argv[3];

fs.readFile(fileName, (err, data) => {
    if (err) throw err;

    fs.writeFile(destPath || 'output', data, (err) => {
        if (err) throw err;
    });
    
    console.log('New file has been created!');
});

这段代码简单地根据输入的文件名和路径,在尝试对文件读取后把它写入目标路径,这对于小文件来说是不成问题的。

现在假设我们有一个大文件(大于4 GB)需要用这段程序来进行备份。就以我的一个达 7.4G 的超高清4K 电影为例子好了,我用上述的程序代码把它从当前目录复制到别的目录。

$ node basic_copy.js cartoonMovie.mkv ~/Documents/bigMovie.mkv

然后在 Ubuntu(Linux )系统下我得到了这段报错:

/home/shobarani/Workspace/basic_copy.js:7
    if (err) throw err;
             ^
RangeError: File size is greater than possible Buffer: 0x7fffffff bytes
    at FSReqWrap.readFileAfterStat [as oncomplete] (fs.js:453:11)

正如你看到的那样,由于 NodeJS 最大只允许写入 2GB 的数据到它的缓冲区,导致了错误发生在读取文件的过程中。为了解决这个问题,当你在进行 I/O 密集操作的时候(复制、处理、压缩等),最好考虑一下内存的情况。

NodeJS 中的 Streams 和 Buffers

为了解决上述问题,我们需要一个办法把大文件切成许多文件块,同时需要一个数据结构去存放这些文件块。一个 buffer 就是用来存储二进制数据的结构。接下来,我们需要一个读写文件块的方法,而 Streams 则提供了这部分能力。

Buffers(缓冲区)

我们能够利用 Buffer 对象轻松地创建一个 buffer。

let buffer = new Buffer(10); # 10 为 buffer 的体积
console.log(buffer); # prints <Buffer 00 00 00 00 00 00 00 00 00 00>

在新版本的 NodeJS (>8)中,你也可以这样写。

let buffer = new Buffer.alloc(10);
console.log(buffer); # prints <Buffer 00 00 00 00 00 00 00 00 00 00>

如果我们已经有了一些数据,比如数组或者别的数据集,我们可以为它们创建一个 buffer。

let name = 'Node JS DEV';
let buffer = Buffer.from(name);
console.log(buffer) # prints <Buffer 4e 6f 64 65 20 4a 53 20 44 45 5>

Buffers 有一些如 buffer.toString()buffer.toJSON() 之类的重要方法,能够深入到其所存储的数据当中去。

我们不会为了优化代码而去直接创建原始 buffer。NodeJS 和 V8 引擎在处理 streams 和网络 socket 的时候就已经在创建内部缓冲区(队列)中实现了这一点。

Streams(流)

简单来说,流就像 NodeJS 对象上的任意门。在计算机网络中,入口是一个输入动作,出口是一个输出动作。我们接下来将继续使用这些术语。

流的类型总共有四种:

  • 可读流(用于读取数据)
  • 可写流(用于写入数据)
  • 双工流(同时可用于读写)
  • 转换流(一种用于处理数据的自定义双工流,如压缩,检查数据等)

下面这句话可以清晰地阐述为什么我们应该使用流。

Stream API (尤其是 stream.pipe() 方法)的一个重要目标是将数据缓冲限制在可接受的水平,这样不同速度的源和目标就不会阻塞可用内存。

我们需要一些办法去完成任务而不至于压垮系统。这也是我们在文章开头就已经提到过的。

image

上面的示意图中我们有两个类型的流,分别是可读流和可写流。.pipe() 方法是一个非常基本的方法,用于连接可读流和可写流。如果你不明白上面的示意图,也没关系,在看完我们的例子以后,你可以回到示意图这里来,那个时候一切都会显得理所当然。管道是一种引人注目的机制,下面我们用两个例子来说明它。

解法1(简单地使用流来复制文件)

让我们设计一种解法来解决前文中大文件复制的问题。首先我们要创建两个流,然后执行接下来的几个步骤。

  1. 监听来自可读流的数据块
  2. 把数据块写进可写流
  3. 跟踪文件复制的进度

我们把这段代码命名为 streams_copy_basic.js

/*
    A file copy with streams and events - Author: Naren Arya
*/

const stream = require('stream');
const fs = require('fs');

let fileName = process.argv[2];
let destPath = process.argv[3];

const readabale = fs.createReadStream(fileName);
const writeable = fs.createWriteStream(destPath || "output");

fs.stat(fileName, (err, stats) => {
    this.fileSize = stats.size;
    this.counter = 1;
    this.fileArray = fileName.split('.');
    
    try {
        this.duplicate = destPath + "/" + this.fileArray[0] + '_Copy.' + this.fileArray[1];
    } catch(e) {
        console.exception('File name is invalid! please pass the proper one');
    }
    
    process.stdout.write(`File: ${this.duplicate} is being created:`);
    
    readabale.on('data', (chunk)=> {
        let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100;
        process.stdout.clearLine();  // clear current text
        process.stdout.cursorTo(0);
        process.stdout.write(`${Math.round(percentageCopied)}%`);
        writeable.write(chunk);
        this.counter += 1;
    });
    
    readabale.on('end', (e) => {
        process.stdout.clearLine();  // clear current text
        process.stdout.cursorTo(0);
        process.stdout.write("Successfully finished the operation");
        return;
    });
    
    readabale.on('error', (e) => {
        console.log("Some error occured: ", e);
    });
    
    writeable.on('finish', () => {
        console.log("Successfully created the file copy!");
    });
    
});

在这段程序中,我们接收用户传入的两个文件路径(源文件和目标文件),然后创建了两个流,用于把数据块从可读流运到可写流。然后我们定义了一些变量去追踪文件复制的进度,然后输出到控制台(此处为 console)。与此同时我们还订阅了一些事件:

data:当一个数据块被读取时触发

end:当一个数据块被可读流所读取完的时候触发

error:当读取数据块的时候出错时触发

运行这段程序,我们可以成功地完成一个大文件(此处为7.4 G)的复制任务。

$ time node streams_copy_basic.js cartoonMovie.mkv ~/Documents/4kdemo.mkv

然而,当我们通过任务管理器观察程序在运行过程中的内存状况时,依旧有一个问题。

image

4.6GB?我们的程序在运行时所消耗的内存,在这里是讲不通的,以及它很有可能会卡死其他的应用程序。

发生了什么?

如果你有仔细观察上图中的读写率,你会发现一些端倪。

Disk Read: 53.4 MiB/s

Disk Write: 14.8 MiB/s

这意味着生产者正在以更快的速度生产,而消费者无法跟上这个速度。计算机为了保存读取的数据块,将多余的数据存储到机器的RAM中。这就是RAM出现峰值的原因。

上述代码在我的机器上运行了3分16秒……

17.16s user 25.06s system 21% cpu 3:16.61 total

解法2(基于流和自动背压的文件复制)

为了克服上述问题,我们可以修改程序来自动调整磁盘的读写速度。这个机制就是背压。我们不需要做太多,只需将可读流导入可写流即可,NodeJS 会负责背压的工作。

让我们将这个程序命名为 streams_copy_efficient.js

/*
    A file copy with streams and piping - Author: Naren Arya
*/

const stream = require('stream');
const fs = require('fs');

let fileName = process.argv[2];
let destPath = process.argv[3];

const readabale = fs.createReadStream(fileName);
const writeable = fs.createWriteStream(destPath || "output");

fs.stat(fileName, (err, stats) => {
    this.fileSize = stats.size;
    this.counter = 1;
    this.fileArray = fileName.split('.');
    
    try {
        this.duplicate = destPath + "/" + this.fileArray[0] + '_Copy.' + this.fileArray[1];
    } catch(e) {
        console.exception('File name is invalid! please pass the proper one');
    }
    
    process.stdout.write(`File: ${this.duplicate} is being created:`);
    
    readabale.on('data', (chunk) => {
        let percentageCopied = ((chunk.length * this.counter) / this.fileSize) * 100;
        process.stdout.clearLine();  // clear current text
        process.stdout.cursorTo(0);
        process.stdout.write(`${Math.round(percentageCopied)}%`);
        this.counter += 1;
    });
    
    readabale.pipe(writeable); // Auto pilot ON!
    
    // In case if we have an interruption while copying
    writeable.on('unpipe', (e) => {
        process.stdout.write("Copy has failed!");
    });
    
});

在这个例子中,我们用一句代码替换了之前的数据块写入操作。

readabale.pipe(writeable); // Auto pilot ON!

这里的 pipe 就是所有魔法发生的原因。它控制了磁盘读写的速度以至于不会阻塞内存(RAM)。

运行一下。

$ time node streams_copy_efficient.js cartoonMovie.mkv ~/Documents/4kdemo.mkv

我们复制了同一个大文件(7.4 GB),让我们来看看内存利用率。

image

震惊!现在 Node 程序仅仅占用了61.9 MiB 的内存。如果你观察到读写速率的话:

Disk Read: 35.5 MiB/s

Disk Write: 35.5 MiB/s

在任意给定的时间内,因为背压的存在,读写速率得以保持一致。更让人惊喜的是,这段优化后的程序代码整整比之前的快了13秒。

12.13s user 28.50s system 22% cpu 3:03.35 total
由于 NodeJS 流和管道,内存负载减少了98.68%,执行时间也减少了。这就是为什么管道是一个强大的存在。

61.9 MiB 是由可读流创建的缓冲区大小。我们还可以使用可读流上的 read 方法为缓冲块分配自定义大小。

const readabale = fs.createReadStream(fileName);
readable.read(no_of_bytes_size);

除了本地文件的复制以外,这个技术还可以用于优化许多 I/O 操作的问题:

  • 处理从卡夫卡到数据库的数据流
  • 处理来自文件系统的数据流,动态压缩并写入磁盘
  • 更多……

源码(Git)

你可以在我的仓库底下找到所有的例子并在自己的机器上测试。
narenaryan/node-backpressure-internals

结论

我写这篇文章的动机,主要是为了说明即使 NodeJS 提供了很好的 API,我们也可能会一不留神就写出性能很差的代码。如果我们能更多地关注其内置的工具,我们便可以更好地优化程序的运行方式。

你在此可以找到更多关于“背压”的资料:
backpressuring-in-streams

完。

查看原文

赞 30 收藏 19 评论 5

jrainlau 赞了文章 · 2019-12-09

有趣的6种图片灰度转换算法

本文转载自blog

转载请注明出处

图片描述

前言

黑白照片的时代虽然已经过去,但现在看到以前的照片,是不是有一种回到过去的感觉,很cool有木有~
看完这篇文章,就可以把彩色照片变成各种各样的黑白的照片啦。

本文完整的在线例子图片灰度算法例子,例子的图片有点多,可能有些慢。

例子的源码位于blog/demo

三原色与灰度

原色是指不能透过其他颜色的混合调配而得出的“基本色”。一般来说叠加型的三原色是红色绿色蓝色,以不同比例将原色混合,可以产生出其他的新颜色。这套原色系统常被称为“RGB色彩空间”,亦即由红(R)绿(G)蓝(B)所组合出的色彩系统。

当这三种原色以等比例叠加在一起时,会变成灰色;若将此三原色的强度均调至最大并且等量重叠时,则会呈现白色。灰度就是没有色彩,RGB色彩分量全部相等。

获取图片的像素数据

算法不区分语言,这里以前端举例。可以使用canvas取得图片某个区域的像素数据

//伪代码
var img = new Image();
img.src = 'xxx.jpg';
var myCanvas = document.querySelector(canvasId);
var canvasCtx = myCanvas.getContext("2d");
canvasCtx.drawImage(img, 0, 0, img.width, img.height);
//图片的像素数据
var data = canvasCtx.getImageData(0, 0, img.width, img.height);

使用getImageData()返回一个ImageData对象,此对象有个data属性就是我们要的数据了,数据是以Uint8ClampedArray 描述的一个一维数组,包含以 RGBA 顺序的数据,数据使用 0 至 255(包含)的整数表示。 所以,一个像素会有4个数据(RGBA),RGB是红绿蓝,A指的是透明度。

举个例子:本文720480的水果图片,一共有720 480 = 259200像素,每个像素又有4个数据,所以数据数组的总长度为259200 * 4 = 1036800。

可以看到图片的数据很长,如果一次性处理很多图片的时候,计算量相当可观,所以例子中会使用worker,把繁重的计算任务交给后台线程。

算法的基本步骤

  1. 取得每一个像素的red,green,blue值。

  2. 使用灰度算法,算出一个灰度值。

  3. 用这个灰度值代替像素原始的red,green,blue值。

比如我们的灰度算法是:

Gray = (Red + Green + Blue) / 3

计算过程:

//伪代码
for(var Pixel in Image){
  var Red = Image[Pixel].Red
  var Green = Image[Pixel].Green
  var Blue = Image[Pixel].Blue

  var Gray = (Red + Green + Blue) / 3

  Image[Pixel].Red = Gray
  Image[Pixel].Green = Gray
  Image[Pixel].Blue = Gray
}

很简单对吧。

很多好吃的鲜艳水果,但是它们马上要变灰了!!

fruits

算法1 - 平均法

使用算法1:

算法1

这是最常见的灰度算法,简单暴力,把它放到第一位。公式是:

 Gray = (Red + Green + Blue) / 3

这个算法可以生成不错灰度值,因为公式简单,所以易于维护和优化。然而它也不是没有缺点,因为简单快速,从人眼的感知角度看,图片的灰度阴影和亮度方面做的还不够好。所以,我们需要更复杂的运算。

算法2 - 基于人眼感知

使用算法2:

算法2

算法1与算法2生成的图片似乎没太大差别,所以增加一个例子,将图片上半部分用算法1,下半部分用算法2。

上半部分是算法1,下半部分是算法2:

算法1+算法2

仔细看的话,中间有一根黑线。上半部分(算法1)比下半部分(算法2)更苍白一些。如果还是看不出来,注意最右边的柠檬,算法1的柠檬反光更强烈,算法2的柠檬更柔和。

第二种算法考虑到了人眼对不同光感知程度不同。人的眼睛内有几种辨别颜色的锥形感光细胞,分别对黄绿色、绿色和蓝紫色的光最敏感。虽然眼球中的椎状细胞并非对红、绿、蓝三色的感受度最强,但是由肉眼的椎状细胞所能感受的光的带宽很大,红、绿、蓝也能够独立刺激这三种颜色的受光体。

人类对红绿蓝三色的感知程度依次是: 绿>红>蓝,所以平均算法从这个角度看是不科学的。应该按照人类对光的感知程度为每个颜色设定一个权重,它们的之间的地位不应该是平等的。

一个图像处理通用的公式是:

Gray = (Red * 0.3 + Green * 0.59 + Blue * 0.11)

可以看到,每个颜色的系数相差很大。

现在对图像灰度处理的最佳公式还存在争议,有一些类似的公式:

Gray = (Red * 0.2126 + Green * 0.7152 + Blue * 0.0722)

or

Gray = (Red * 0.299 + Green * 0.587 + Blue * 0.114)

它们只是在系数上存在一些偏差,大体的比值差不多。

算法3 - 去饱和

使用算法3:

算法3

在说这个算法之前,先说说RGB,大多数程序员都使用RGB模型,每一种颜色都可以由红绿蓝组成,RGB对计算机来说可以很好的描述颜色,但对于人类而言就很难理解了。如果升国旗的时候说,“五星红旗多么RGB(255, 0, 42)”,可能会被暴打一顿。但我说鲜红的五星红旗,老师可能会点头称赞。

所以为了更通俗易懂,有时我们选择HLS模型描述颜色,这三个字母分别表示Hue(色调)、Saturation(饱和度)、Lightness(亮度)。色调,取值为:0 - 360,0(或360)表示红色,120表示绿色,240表示蓝色,也可取其他数值来指定颜色。饱和度,取值为:0.0% - 100.0%,它通常指颜色的鲜艳程度。亮度,取值为:0.0% - 100.0%,黑色的亮度为0。

去饱和的过程就是把RGB转换为HLS,然后将饱和度设为0。因此,我们需要取一种颜色,转换它为最不饱和的值。这个数学公式比本文介绍的更复杂,这里提供一个简单的公式,一个像素可以被去饱和通过计算RGB中的最大值和最小值的中间值:

Gray = ( Math.max(Red, Green, Blue) + Math.min(Red, Green, Blue) ) / 2

去饱和后,图片立体感减弱,但是更柔和。对比算法2,可以很明显的看出差异,从效果上看,可能大多数人都喜欢算法2,算法3是目前为止,处理的图片立体感最弱,最黑暗的。

算法4 - 分解

取最大值

算法4max

取最小值

算法4min

分解算法可以认为是去饱和更简单一种的方式。分解是基于每一个像素的,只取RGB的最大值或者最小值。

最大值分解:

Gray = Math.max(Red, Green, Blue)

最小值分解:

Gray = Math.min(Red, Green, Blue)

正如上面展现的,最大值分解提供了更明亮的图,而最小值分解提供了更黑暗的图。

算法5 - 单一通道

取红色通道

算法5red

取绿色通道

算法5green

取蓝色通道

算法5blue

图片变灰更快捷的方法,这个方法不用做任何计算,取一个通道的值直接作为灰度值。

Gray = Red

or

Gray = Green

or

Gray = Blue

不管相不相信,大多数数码相机都用这个算法生成灰度图片。很难预测这种转换的结果,所以这种算法多用于艺术效果。

算法6 - 自定义灰度阴影

NumberOfShades = 4

算法6a

这是到目前为止最有趣的算法,允许用户提供一个灰色阴影值,值的范围在2-256。2的结果是一张全白的图片,256的结果和算法1一样。

NumberOfShades = 16

算法6b

该算法通过选择阴影值来工作,它的公式有点复杂

ConversionFactor = 255 / (NumberOfShades - 1)
AverageValue = (Red + Green + Blue) / 3
Gray = Math.round((AverageValue / ConversionFactor) + 0.5) * ConversionFactor
  • NumberOfShades 的范围在2-256。

  • 从技术上说,任何灰度算法都可以计算AverageValue,它仅仅提供一个初始灰度的估计值。

  • “+ 0.5” 是一个可选参数,用于模拟四舍五入。

小节

这是一篇很有趣的文章,不仅仅是介绍灰度算法,对了解图片的处理过程也很有帮助。

References

查看原文

赞 7 收藏 17 评论 0

jrainlau 发布了文章 · 2019-12-09

利用 JS 实现多种图片相似度算法

image

在搜索领域,早已出现了“查找相似图片/相似商品”的相关功能,如 Google 搜图,百度搜图,淘宝的拍照搜商品等。要实现类似的计算图片相似度的功能,除了使用听起来高大上的“人工智能”以外,其实通过 js 和几种简单的算法,也能八九不离十地实现类似的效果。

在阅读本文之前,强烈建议先阅读完阮一峰于多年所撰写的《相似图片搜索的原理》相关文章,本文所涉及的算法也来源于其中。

体验地址:https://img-compare.netlify.com/

特征提取算法

为了便于理解,每种算法都会经过“特征提取”和“特征比对”两个步骤进行。接下来将着重对每种算法的“特征提取”步骤进行详细解读,而“特征比对”则单独进行阐述。

平均哈希算法

参考阮大的文章,“平均哈希算法”主要由以下几步组成:

第一步,缩小尺寸为8×8,以去除图片的细节,只保留结构、明暗等基本信息,摒弃不同尺寸、比例带来的图片差异。

第二步,简化色彩。将缩小后的图片转为灰度图像。

第三步,计算平均值。计算所有像素的灰度平均值。

第四步,比较像素的灰度。将64个像素的灰度,与平均值进行比较。大于或等于平均值,记为1;小于平均值,记为0。

第五步,计算哈希值。将上一步的比较结果,组合在一起,就构成了一个64位的整数,这就是这张图片的指纹。

第六步,计算哈希值的差异,得出相似度(汉明距离或者余弦值)。

明白了“平均哈希算法”的原理及步骤以后,就可以开始编码工作了。为了让代码可读性更高,本文的所有例子我都将使用 typescript 来实现。

图片压缩:

我们采用 canvas 的 drawImage() 方法实现图片压缩,后使用 getImageData() 方法获取 ImageData 对象。

export function compressImg (imgSrc: string, imgWidth: number = 8): Promise<ImageData> {
  return new Promise((resolve, reject) => {
    if (!imgSrc) {
      reject('imgSrc can not be empty!')
    }
    const canvas = document.createElement('canvas')
    const ctx = canvas.getContext('2d')
    const img = new Image()
    img.crossOrigin = 'Anonymous'
    img.onload = function () {
      canvas.width = imgWidth
      canvas.height = imgWidth
      ctx?.drawImage(img, 0, 0, imgWidth, imgWidth)
      const data = ctx?.getImageData(0, 0, imgWidth, imgWidth) as ImageData
      resolve(data)
    }
    img.src = imgSrc
  })
}

可能有读者会问,为什么使用 canvas 可以实现图片压缩呢?简单来说,为了把“大图片”绘制到“小画布”上,一些相邻且颜色相近的像素往往会被删减掉,从而有效减少了图片的信息量,因此能够实现压缩的效果:
image

在上面的 compressImg() 函数中,我们利用 new Image() 加载图片,然后设定一个预设的图片宽高值让图片压缩到指定的大小,最后获取到压缩后的图片的 ImageData 数据——这也意味着我们能获取到图片的每一个像素的信息。

关于 ImageData,可以参考 MDN 的文档介绍

图片灰度化

为了把彩色的图片转化成灰度图,我们首先要明白“灰度图”的概念。在维基百科里是这么描述灰度图像的:

在计算机领域中,灰度(Gray scale)数字图像是每个像素只有一个采样颜色的图像。

大部分情况下,任何的颜色都可以通过三种颜色通道(R, G, B)的亮度以及一个色彩空间(A)来组成,而一个像素只显示一种颜色,因此可以得到“像素 => RGBA”的对应关系。而“每个像素只有一个采样颜色”,则意味着组成这个像素的三原色通道亮度相等,因此只需要算出 RGB 的平均值即可:

// 根据 RGBA 数组生成 ImageData
export function createImgData (dataDetail: number[]) {
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d')
  const imgWidth = Math.sqrt(dataDetail.length / 4)
  const newImageData = ctx?.createImageData(imgWidth, imgWidth) as ImageData
  for (let i = 0; i < dataDetail.length; i += 4) {
    let R = dataDetail[i]
    let G = dataDetail[i + 1]
    let B = dataDetail[i + 2]
    let Alpha = dataDetail[i + 3]

    newImageData.data[i] = R
    newImageData.data[i + 1] = G
    newImageData.data[i + 2] = B
    newImageData.data[i + 3] = Alpha
  }
  return newImageData
}

export function createGrayscale (imgData: ImageData) {
  const newData: number[] = Array(imgData.data.length)
  newData.fill(0)
  imgData.data.forEach((_data, index) => {
    if ((index + 1) % 4 === 0) {
      const R = imgData.data[index - 3]
      const G = imgData.data[index - 2]
      const B = imgData.data[index - 1]

      const gray = ~~((R + G + B) / 3)
      newData[index - 3] = gray
      newData[index - 2] = gray
      newData[index - 1] = gray
      newData[index] = 255 // Alpha 值固定为255
    }
  })
  return createImgData(newData)
}

ImageData.data 是一个 Uint8ClampedArray 数组,可以理解为“RGBA数组”,数组中的每个数字取值为0~255,每4个数字为一组,表示一个像素的 RGBA 值。由于ImageData 为只读对象,所以要另外写一个 creaetImageData() 方法,利用 context.createImageData() 来创建新的 ImageData 对象。

拿到灰度图像以后,就可以进行指纹提取的操作了。

指纹提取

在“平均哈希算法”中,若灰度图的某个像素的灰度值大于平均值,则视为1,否则为0。把这部分信息组合起来就是图片的指纹。由于我们已经拿到了灰度图的 ImageData 对象,要提取指纹也就变得很容易了:

export function getHashFingerprint (imgData: ImageData) {
  const grayList = imgData.data.reduce((pre: number[], cur, index) => {
    if ((index + 1) % 4 === 0) {
      pre.push(imgData.data[index - 1])
    }
    return pre
  }, [])
  const length = grayList.length
  const grayAverage = grayList.reduce((pre, next) => (pre + next), 0) / length
  return grayList.map(gray => (gray >= grayAverage ? 1 : 0)).join('')
}

image


通过上述一连串的步骤,我们便可以通过“平均哈希算法”获取到一张图片的指纹信息(示例是大小为8×8的灰度图):
image

感知哈希算法

关于“感知哈希算法”的详细介绍,可以参考这篇文章:《基于感知哈希算法的视觉目标跟踪》

image

简单来说,该算法经过离散余弦变换以后,把图像从像素域转化到了频率域,而携带了有效信息的低频成分会集中在 DCT 矩阵的左上角,因此我们可以利用这个特性提取图片的特征。

该算法的步骤如下:

  • 缩小尺寸:pHash以小图片开始,但图片大于88,3232是最好的。这样做的目的是简化了DCT的计算,而不是减小频率。
  • 简化色彩:将图片转化成灰度图像,进一步简化计算量。
  • 计算DCT:计算图片的DCT变换,得到32*32的DCT系数矩阵。
  • 缩小DCT:虽然DCT的结果是3232大小的矩阵,但我们只要保留左上角的88的矩阵,这部分呈现了图片中的最低频率。
  • 计算平均值:如同均值哈希一样,计算DCT的均值。
  • 计算hash值:这是最主要的一步,根据8*8的DCT矩阵,设置0或1的64位的hash值,大于等于DCT均值的设为”1”,小于DCT均值的设为“0”。组合在一起,就构成了一个64位的整数,这就是这张图片的指纹。

回到代码中,首先添加一个 DCT 方法:

function memoizeCosines (N: number, cosMap: any) {
  cosMap = cosMap || {}
  cosMap[N] = new Array(N * N)

  let PI_N = Math.PI / N

  for (let k = 0; k < N; k++) {
    for (let n = 0; n < N; n++) {
      cosMap[N][n + (k * N)] = Math.cos(PI_N * (n + 0.5) * k)
    }
  }
  return cosMap
}

function dct (signal: number[], scale: number = 2) {
  let L = signal.length
  let cosMap: any = null

  if (!cosMap || !cosMap[L]) {
    cosMap = memoizeCosines(L, cosMap)
  }

  let coefficients = signal.map(function () { return 0 })

  return coefficients.map(function (_, ix) {
    return scale * signal.reduce(function (prev, cur, index) {
      return prev + (cur * cosMap[L][index + (ix * L)])
    }, 0)
  })
}

然后添加两个矩阵处理方法,分别是把经过 DCT 方法生成的一维数组升维成二维数组(矩阵),以及从矩阵中获取其“左上角”内容。

// 一维数组升维
function createMatrix (arr: number[]) {
  const length = arr.length
  const matrixWidth = Math.sqrt(length)
  const matrix = []
  for (let i = 0; i < matrixWidth; i++) {
    const _temp = arr.slice(i * matrixWidth, i * matrixWidth + matrixWidth)
    matrix.push(_temp)
  }
  return matrix
}

// 从矩阵中获取其“左上角”大小为 range × range 的内容
function getMatrixRange (matrix: number[][], range: number = 1) {
  const rangeMatrix = []
  for (let i = 0; i < range; i++) {
    for (let j = 0; j < range; j++) {
      rangeMatrix.push(matrix[i][j])
    }
  }
  return rangeMatrix
}

复用之前在“平均哈希算法”中所写的灰度图转化函数createGrayscale(),我们可以获取“感知哈希算法”的特征值:

export function getPHashFingerprint (imgData: ImageData) {
  const dctData = dct(imgData.data as any)
  const dctMatrix = createMatrix(dctData)
  const rangeMatrix = getMatrixRange(dctMatrix, dctMatrix.length / 8)
  const rangeAve = rangeMatrix.reduce((pre, cur) => pre + cur, 0) / rangeMatrix.length
  return rangeMatrix.map(val => (val >= rangeAve ? 1 : 0)).join('')
}

image

颜色分布法

首先摘抄一段阮大关于“颜色分布法“的描述:
image

阮大把256种颜色取值简化成了4种。基于这个原理,我们在进行颜色分布法的算法设计时,可以把这个区间的划分设置为可修改的,唯一的要求就是区间的数量必须能够被256整除。算法如下:

// 划分颜色区间,默认区间数目为4个
// 把256种颜色取值简化为4种
export function simplifyColorData (imgData: ImageData, zoneAmount: number = 4) {
  const colorZoneDataList: number[] = []
  const zoneStep = 256 / zoneAmount
  const zoneBorder = [0] // 区间边界
  for (let i = 1; i <= zoneAmount; i++) {
    zoneBorder.push(zoneStep * i - 1)
  }
  imgData.data.forEach((data, index) => {
    if ((index + 1) % 4 !== 0) {
      for (let i = 0; i < zoneBorder.length; i++) {
        if (data > zoneBorder[i] && data <= zoneBorder[i + 1]) {
          data = i
        }
      }
    }
    colorZoneDataList.push(data)
  })
  return colorZoneDataList
}

image

把颜色取值进行简化以后,就可以把它们归类到不同的分组里面去:

export function seperateListToColorZone (simplifiedDataList: number[]) {
  const zonedList: string[] = []
  let tempZone: number[] = []
  simplifiedDataList.forEach((data, index) => {
    if ((index + 1) % 4 !== 0) {
      tempZone.push(data)
    } else {
      zonedList.push(JSON.stringify(tempZone))
      tempZone = []
    }
  })
  return zonedList
}

image

最后只需要统计每个相同的分组的总数即可:

export function getFingerprint (zonedList: string[], zoneAmount: number = 16) {
  const colorSeperateMap: {
    [key: string]: number
  } = {}
  for (let i = 0; i < zoneAmount; i++) {
    for (let j = 0; j < zoneAmount; j++) {
      for (let k = 0; k < zoneAmount; k++) {
        colorSeperateMap[JSON.stringify([i, j, k])] = 0
      }
    }
  }
  zonedList.forEach(zone => {
    colorSeperateMap[zone]++
  })
  return Object.values(colorSeperateMap)
}

image

内容特征法

”内容特征法“是指把图片转化为灰度图后再转化为”二值图“,然后根据像素的取值(黑或白)形成指纹后进行比对的方法。这种算法的核心是找到一个“阈值”去生成二值图。
image

对于生成灰度图,有别于在“平均哈希算法”中提到的取 RGB 均值的办法,在这里我们使用加权的方式去实现。为什么要这么做呢?这里涉及到颜色学的一些概念。

具体可以参考这篇《Grayscale to RGB Conversion》,下面简单梳理一下。

采用 RGB 均值的灰度图是最简单的一种办法,但是它忽略了红、绿、蓝三种颜色的波长以及对整体图像的影响。以下面图为示例,如果直接取得 RGB 的均值作为灰度,那么处理后的灰度图整体来说会偏暗,对后续生成二值图会产生较大的干扰。

image

那么怎么改善这种情况呢?答案就是为 RGB 三种颜色添加不同的权重。鉴于红光有着更长的波长,而绿光波长更短且对视觉的刺激相对更小,所以我们要有意地减小红光的权重而提升绿光的权重。经过统计,比较好的权重配比是 R:G:B = 0.299:0.587:0.114。

image

于是我们可以得到灰度处理函数:

enum GrayscaleWeight {
  R = .299,
  G = .587,
  B = .114
}

function toGray (imgData: ImageData) {
  const grayData = []
  const data = imgData.data

  for (let i = 0; i < data.length; i += 4) {
    const gray = ~~(data[i] * GrayscaleWeight.R + data[i + 1] * GrayscaleWeight.G + data[i + 2] * GrayscaleWeight.B)
    data[i] = data[i + 1] = data[i + 2] = gray
    grayData.push(gray)
  }

  return grayData
}

上述函数返回一个 grayData 数组,里面每个元素代表一个像素的灰度值(因为 RBG 取值相同,所以只需要一个值即可)。接下来则使用“大津法”(Otsu's method)去计算二值图的阈值。关于“大津法”,阮大的文章已经说得很详细,在这里就不展开了。我在这个地方找到了“大津法”的 Java 实现,后来稍作修改,把它改为了 js 版本:

/ OTSU algorithm
// rewrite from http://www.labbookpages.co.uk/software/imgProc/otsuThreshold.html
export function OTSUAlgorithm (imgData: ImageData) {
  const grayData = toGray(imgData)
  let ptr = 0
  let histData = Array(256).fill(0)
  let total = grayData.length

  while (ptr < total) {
    let h = 0xFF & grayData[ptr++]
    histData[h]++
  }

  let sum = 0
  for (let i = 0; i < 256; i++) {
    sum += i * histData[i]
  }

  let wB = 0
  let wF = 0
  let sumB = 0
  let varMax = 0
  let threshold = 0

  for (let t = 0; t < 256; t++) {
    wB += histData[t]
    if (wB === 0) continue
    wF = total - wB
    if (wF === 0) break

    sumB += t * histData[t]

    let mB = sumB / wB
    let mF = (sum - sumB) / wF

    let varBetween = wB * wF * (mB - mF) ** 2

    if (varBetween > varMax) {
      varMax = varBetween
      threshold = t
    }
  }

  return threshold
}

OTSUAlgorithm() 函数接收一个 ImageData 对象,经过上一步的 toGray() 方法获取到灰度值列表以后,根据“大津法”算出最佳阈值然后返回。接下来使用这个阈值对原图进行处理,即可获取二值图。

export function binaryzation (imgData: ImageData, threshold: number) {
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d')
  const imgWidth = Math.sqrt(imgData.data.length / 4)
  const newImageData = ctx?.createImageData(imgWidth, imgWidth) as ImageData
  for (let i = 0; i < imgData.data.length; i += 4) {
    let R = imgData.data[i]
    let G = imgData.data[i + 1]
    let B = imgData.data[i + 2]
    let Alpha = imgData.data[i + 3]
    let sum = (R + G + B) / 3

    newImageData.data[i] = sum > threshold ? 255 : 0
    newImageData.data[i + 1] = sum > threshold ? 255 : 0
    newImageData.data[i + 2] = sum > threshold ? 255 : 0
    newImageData.data[i + 3] = Alpha
  }
  return newImageData
}

image

若图片大小为 N×N,根据二值图“非黑即白”的特性,我们便可以得到一个 N×N 的 0-1 矩阵,也就是指纹:

image

特征比对算法

经过不同的方式取得不同类型的图片指纹(特征)以后,应该怎么去比对呢?这里将介绍三种比对算法,然后分析这几种算法都适用于哪些情况。

汉明距离

摘一段维基百科关于“汉明距离”的描述:

在信息论中,两个等长字符串之间的汉明距离(英语:Hamming distance)是两个字符串对应位置的不同字符的个数。换句话说,它就是将一个字符串变换成另外一个字符串所需要替换的字符个数。

例如:

  • 1011101与1001001之间的汉明距离是2。
  • 2143896与2233796之间的汉明距离是3。
  • "toned"与"roses"之间的汉明距离是3。

明白了含义以后,我们可以写出计算汉明距离的方法:

export function hammingDistance (str1: string, str2: string) {
  let distance = 0
  const str1Arr = str1.split('')
  const str2Arr = str2.split('')
  str1Arr.forEach((letter, index) => {
    if (letter !== str2Arr[index]) {
      distance++
    }
  })
  return distance
}

使用这个 hammingDistance() 方法,来验证下维基百科上的例子:
image

验证结果符合预期。

知道了汉明距离,也就可以知道两个等长字符串之间的相似度了(汉明距离越小,相似度越大):

相似度 = (字符串长度 - 汉明距离) / 字符串长度

余弦相似度

从维基百科中我们可以了解到关于余弦相似度的定义:

余弦相似性通过测量两个向量的夹角的余弦值来度量它们之间的相似性。0度角的余弦值是1,而其他任何角度的余弦值都不大于1;并且其最小值是-1。从而两个向量之间的角度的余弦值确定两个向量是否大致指向相同的方向。两个向量有相同的指向时,余弦相似度的值为1;两个向量夹角为90°时,余弦相似度的值为0;两个向量指向完全相反的方向时,余弦相似度的值为-1。这结果是与向量的长度无关的,仅仅与向量的指向方向相关。余弦相似度通常用于正空间,因此给出的值为0到1之间。

注意这上下界对任何维度的向量空间中都适用,而且余弦相似性最常用于高维正空间。

image

余弦相似度可以计算出两个向量之间的夹角,从而很直观地表示两个向量在方向上是否相似,这对于计算两个 N×N 的 0-1 矩阵的相似度来说非常有用。根据余弦相似度的公式,我们可以把它的 js 实现写出来:

export function cosineSimilarity (sampleFingerprint: number[], targetFingerprint: number[]) {
  // cosθ = ∑n, i=1(Ai × Bi) / (√∑n, i=1(Ai)^2) × (√∑n, i=1(Bi)^2) = A · B / |A| × |B|
  const length = sampleFingerprint.length
  let innerProduct = 0
  for (let i = 0; i < length; i++) {
    innerProduct += sampleFingerprint[i] * targetFingerprint[i]
  }
  let vecA = 0
  let vecB = 0
  for (let i = 0; i < length; i++) {
    vecA += sampleFingerprint[i] ** 2
    vecB += targetFingerprint[i] ** 2
  }
  const outerProduct = Math.sqrt(vecA) * Math.sqrt(vecB)
  return innerProduct / outerProduct
}

两种比对算法的适用场景

明白了“汉明距离”和“余弦相似度”这两种特征比对算法以后,我们就要去看看它们分别适用于哪些特征提取算法的场景。

首先来看“颜色分布法”。在“颜色分布法”里面,我们把一张图的颜色进行区间划分,通过统计不同颜色区间的数量来获取特征,那么这里的特征值就和“数量”有关,也就是非 0-1 矩阵。

image

显然,要比较两个“颜色分布法”特征的相似度,“汉明距离”是不适用的,只能通过“余弦相似度”来进行计算。

接下来看“平均哈希算法”和“内容特征法”。从结果来说,这两种特征提取算法都能获得一个 N×N 的 0-1 矩阵,且矩阵内元素的值和“数量”无关,只有 0-1 之分。所以它们同时适用于通过“汉明距离”和“余弦相似度”来计算相似度。

image

计算精度

明白了如何提取图片的特征以及如何进行比对以后,最重要的就是要了解它们对于相似度的计算精度。

本文所讲的相似度仅仅是通过客观的算法来实现,而判断两张图片“像不像”却是一个很主观的问题。于是我写了一个简单的服务,可以自行把两张图按照不同的算法和精度去计算相似度:

https://img-compare.netlify.com/

经过对不同素材的多方比对,我得出了下列几个非常主观的结论。

  • 对于两张颜色较为丰富,细节较多的图片来说,“颜色分布法”的计算结果是最符合直觉的。
    image
  • 对于两张内容相近但颜色差异较大的图片来说,“内容特征法”和“平均/感知哈希算法”都能得到符合直觉的结果。
    image
  • 针对“颜色分布法“,区间的划分数量对计算结果影响较大,选择合适的区间很重要。
    image

总结一下,三种特征提取算法和两种特征比对算法各有优劣,在实际应用中应该针对不同的情况灵活选用。

总结

本文是在拜读阮一峰的两篇《相似图片搜索的原理》之后,经过自己的实践总结以后而成。由于对色彩、数学等领域的了解只停留在浅显的层面,文章难免有谬误之处,如果有发现表述得不正确的地方,欢迎留言指出,我会及时予以更正。

查看原文

赞 102 收藏 65 评论 5

jrainlau 发布了文章 · 2019-10-09

一张图理清 Vue 3.0 的响应式系统

本文首发于我的博客:《一张图理清 Vue 3.0 的响应式系统》

随着 Vue 3.0 Pre Alpha 版本的公布,我们得以一窥其源码的实现。Vue 最巧妙的特性之一是其响应式系统,而我们也能够在仓库的 packages/reactivity 模块下找到对应的实现。虽然源码的代码量不多,网上的分析文章也有一堆,但是要想清晰地理解响应式原理的具体实现过程,还是挺费脑筋的事情。经过一天的研究和整理,我把其响应式系统的原理总结成了一张图,而本文也将围绕这张图去讲述具体的实现过程。

vue 3 响应式系统原理

文章涉及到的代码我也已经上传到仓库,结合代码阅读本文会更为流畅哦!

一个基本的例子

Vue 3.0 的响应式系统是独立的模块,可以完全脱离 Vue 而使用,所以我们在 clone 了源码下来以后,可以直接在 packages/reactivity 模块下调试。

  1. 在项目根目录运行 yarn dev reactivity,然后进入 packages/reactivity 目录找到产出的 dist/reactivity.global.js 文件。
  2. 新建一个 index.html,写入如下代码:

    <script data-original="./dist/reactivity.global.js"></script>
    <script>
    const { reactive, effect } = VueObserver
    
    const origin = {
      count: 0
    }
    const state = reactive(origin)
    
    const fn = () => {
      const count = state.count
      console.log(`set count to ${count}`)
    }
    effect(fn)
    </script>
  3. 在浏览器打开该文件,于控制台执行 state.count++,便可看到输出 set count to 1

在上述的例子中,我们使用 reactive() 函数把 origin 对象转化成了 Proxy 对象 state;使用 effect() 函数把 fn() 作为响应式回调。当 state.count 发生变化时,便触发了 fn()。接下来我们将以这个例子结合上文的流程图,来讲解这套响应式系统是怎么运行的。

初始化阶段

image

在初始化阶段,主要做了两件事。

  1. origin 对象转化成响应式的 Proxy 对象 state
  2. 把函数 fn() 作为一个响应式的 effect 函数。

首先我们来分析第一件事。

大家都知道,Vue 3.0 使用了 Proxy 来代替之前的 Object.defineProperty(),改写了对象的 getter/setter,完成依赖收集和响应触发。但是在这一阶段中,我们暂时先不管它是如何改写对象的 getter/setter 的,这个在后续的”依赖收集阶段“会详细说明。为了简单起见,我们可以把这部分的内容浓缩成一个只有两行代码的 reactive() 函数:

export function reactive(target) {
  const observed = new Proxy(target, handler)
  return observed
}
完整代码在 reactive.js。这里的 handler 就是改造 getter/setter 的关键,我们放到后文讲解。

接下来我们分析第二件事。

当一个普通的函数 fn()effect() 包裹之后,就会变成一个响应式的 effect 函数,而 fn() 也会被立即执行一次

由于在 fn() 里面有引用到 Proxy 对象的属性,所以这一步会触发对象的 getter,从而启动依赖收集。

除此之外,这个 effect 函数也会被压入一个名为”activeReactiveEffectStack“(此处为 effectStack)的栈中,供后续依赖收集的时候使用。

来看看代码(完成代码请看 effect.js):

export function effect (fn) {
  // 构造一个 effect
  const effect = function effect(...args) {
    return run(effect, fn, args)
  }
  // 立即执行一次
  effect()
  return effect
}

export function run(effect, fn, args) {
  if (effectStack.indexOf(effect) === -1) {
    try {
      // 往池子里放入当前 effect
      effectStack.push(effect)
      // 立即执行一遍 fn()
      // fn() 执行过程会完成依赖收集,会用到 effect
      return fn(...args)
    } finally {
      // 完成依赖收集后从池子中扔掉这个 effect
      effectStack.pop()
    }
  }
}

至此,初始化阶段已经完成。接下来就是整个系统最关键的一步——依赖收集阶段。

依赖收集阶段

image

这个阶段的触发时机,就是在 effect 被立即执行,其内部的 fn() 触发了 Proxy 对象的 getter 的时候。简单来说,只要执行到类似 state.count 的语句,就会触发 state 的 getter。

依赖收集阶段最重要的目的,就是建立一份”依赖收集表“,也就是图示的”targetMap"。targetMap 是一个 WeakMap,其 key 值是当前的 Proxy 对象 state代理前的对象origin,而 value 则是该对象所对应的 depsMap。

depsMap 是一个 Map,key 值为触发 getter 时的属性值(此处为 count),而 value 则是触发过该属性值所对应的各个 effect。

还是有点绕?那么我们再举个例子。假设有个 Proxy 对象和 effect 如下:

const state = reactive({
  count: 0,
  age: 18
})

const effect1 = effect(() => {
  console.log('effect1: ' + state.count)
})

const effect2 = effect(() => {
  console.log('effect2: ' + state.age)
})

const effect3 = effect(() => {
  console.log('effect3: ' + state.count, state.age)
})

那么这里的 targetMap 应该为这个样子:
image

这样,{ target -> key -> dep } 的对应关系就建立起来了,依赖收集也就完成了。代码如下:

export function track (target, operationType, key) {
  const effect = effectStack[effectStack.length - 1]
  if (effect) {
    let depsMap = targetMap.get(target)
    if (depsMap === void 0) {
      targetMap.set(target, (depsMap = new Map()))
    }

    let dep = depsMap.get(key)
    if (dep === void 0) {
      depsMap.set(key, (dep = new Set()))
    }

    if (!dep.has(effect)) {
      dep.add(effect)
    }
  }
}

弄明白依赖收集表 targetMap 是非常重要的,因为这是整个响应式系统核心中的核心。

响应阶段

回顾上一章节的例子,我们得到了一个 { count: 0, age: 18 } 的 Proxy,并构造了三个 effect。在控制台上看看效果:
image

效果符合预期,那么它是怎么实现的呢?首先来看看这个阶段的原理图:

vue 3 响应式系统原理

当修改对象的某个属性值的时候,会触发对应的 setter。

setter 里面的 trigger() 函数会从依赖收集表里找到当前属性对应的各个 dep,然后把它们推入到 effectscomputedEffects(计算属性) 队列中,最后通过 scheduleRun() 挨个执行里面的 effect。

由于已经建立了依赖收集表,所以要找到属性所对应的 dep 也就轻而易举了,可以看看具体的代码实现

export function trigger (target, operationType, key) {
  // 取得对应的 depsMap
  const depsMap = targetMap.get(target)
  if (depsMap === void 0) {
    return
  }
  // 取得对应的各个 dep
  const effects = new Set()
  if (key !== void 0) {
    const dep = depsMap.get(key)
    dep && dep.forEach(effect => {
      effects.add(effect)
    })
  }
  // 简化版 scheduleRun,挨个执行 effect
  effects.forEach(effect => {
    effect()
  })
}
这里的代码没有处理诸如数组的 length 被修改的一些特殊情况,感兴趣的读者可以查看 vue-next 对应的源码,或者这篇文章,看看这些情况都是怎么处理的。

至此,响应式阶段完成。

总结

阅读源码的过程充满了挑战性,但同时也常常被 Vue 的一些实现思路给惊艳到,收获良多。本文按照响应式系统的运行过程,划分了”初始化“,”依赖收集“和”响应式“三个阶段,分别阐述了各个阶段所做的事情,应该能够较好地帮助读者理解其核心思路。最后附上文章实例代码的仓库地址,有兴趣的读者可以自行把玩:

tiny-reactive

查看原文

赞 65 收藏 42 评论 4

jrainlau 发布了文章 · 2019-09-18

探索如何使用 JSON.stringify() 去序列化一个 Error

image

最近在做 Node 服务端需求的时候,遇到了几次服务端报错的问题。打 log 发现均是一些 Error,但是它们都没法很好地透传给前端浏览器,出现问题只能查看服务端机器的日志,调试起来非常不方便。思考了一下,服务端的内容都是通过 JSON.stringify() 处理,然后设置 Content-type: text/json 的响应头以后再传给前端的,如果 Error 也能够被这样处理,那么调试起来就方便多了。

举个例子

说到 JSON.stringify() 这个方法,相信所有玩过 JS 的同学都不会陌生。它能够方便地把一个对象转化成字符串,在不同的场景中都有着极大的用处。但是它也有一个较大的缺点,无法直接处理诸如 Error 一类的对象。

首先来看个例子:

const err = new Error('This is an error')
JSON.stringify(err)

// => "{}"

在控制台运行上述代码后会发现,JSON.stringify() 的结果是一个字符串的 "{}",里面没有任何有效内容。这是否意味着 JSON.stringify() 确实无法处理 Error 呢?下面我们来看看在 MDN 里这个函数是如何定义的。

MDN 定义

首先来看看描述

JSON.stringify()将值转换为相应的JSON格式:

  • 转换值如果有toJSON()方法,该方法定义什么值将被序列化。
  • 非数组对象的属性不能保证以特定的顺序出现在序列化后的字符串中。
  • 布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。
  • undefined、任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)。函数、undefined被单独转换时,会返回undefined,如JSON.stringify(function(){}) or JSON.stringify(undefined).
  • 对包含循环引用的对象(对象之间相互引用,形成无限循环)执行此方法,会抛出错误。
  • 所有以 symbol 为属性键的属性都会被完全忽略掉,即便 replacer 参数中强制指定包含了它们。
  • Date日期调用了toJSON()将其转换为了string字符串(同Date.toISOString()),因此会被当做字符串处理。
  • NaN和Infinity格式的数值及null都会被当做null。
  • 其他类型的对象,包括Map/Set/weakMap/weakSet,仅会序列化可枚举的属性。

列了那么多其实是为了凑字数我们只看最后一条描述:

其他类型的对象,包括Map/Set/weakMap/weakSet,仅会序列化可枚举的属性。

“仅会序列化可枚举的属性”,是什么意思呢?众所周知,在 JS 的世界中一切皆对象,对象有着不同的属性,这些属性是否可枚举,我们用 enumerable 来定义。

对象属性的 enumerable

举个例子,我们用 obj = { a: 1, b: 2, c: 3 } 来定义一个对象,然后设置它的 c 属性为“不可枚举”,看看效果会如何:

首先看处理前的效果:

const obj = { a: 1, b: 2, c: 3 }
JSON.stringify(obj)

// => "{"a":1,"b":2,"c":3}"

再看处理后的效果:

const obj = { a: 1, b: 2, c: 3 }

Object.defineProperty(obj, 'c', {
  value: 3,
  enumerable: false
})

JSON.stringify(obj)

// => "{"a":1,"b":2}"

可以看到,在对 c 属性设置为不可枚举以后,JSON.stringify() 便不再对其进行序列化。

我们把问题再深入一些,有没有办法能够获取一个对象中包含不可枚举在内的所有属性呢?答案是使用 Object.getOwnPropertyNames() 方法。

依然是刚刚被改装过的 obj 对象,我们来看看它所包含的所有属性:

Object.getOwnPropertyNames(obj)

// => ["a", "b", "c"]

不可枚举的 c 属性也被获取到了!

用同样的方法,我们来看看一个 Error 都包含哪些属性:

const err = new Error('This is an error')
Object.getOwnPropertyNames(err)

// => ["stack", "message"]

可以看到,Error 包含了 stackmessage 两个属性,它们均可以使用点运算符 .err 实例里面拿到。

既然我们已经能够获取 Error 实例的不可枚举属性及其内容,那么距离使用 JSON.stringify() 序列化 Error 也已经不远了!

JSON.stringify() 的第二个参数

JSON.stringify() 可以接收三个参数:

语法

JSON.stringify(value[, replacer [, space]])

value

将要序列化成 一个JSON 字符串的值。

replacer 可选

如果该参数是一个函数,则在序列化过程中,被序列化的值的每个属性都会经过该函数的转换和处理;如果该参数是一个数组,则只有包含在这个数组中的属性名才会被序列化到最终的 JSON 字符串中;如果该参数为null或者未提供,则对象所有的属性都会被序列化。

space 可选

指定缩进用的空白字符串,用于美化输出(pretty-print);如果参数是个数字,它代表有多少的空格;上限为10。该值若小于1,则意味着没有空格;如果该参数为字符串(字符串的前十个字母),该字符串将被作为空格;如果该参数没有提供(或者为null)将没有空格。
返回值 节
一个表示给定值的JSON字符串。

我们来看 replacer 的用法:

……如果该参数是一个数组,则只有包含在这个数组中的属性名才会被序列化到最终的 JSON 字符串中……

依然使用上文的 obj 为例子:

const obj = { a: 1, b: 2, c: 3 }

Object.defineProperty(obj, 'c', {
  value: 3,
  enumerable: false
})

JSON.stringify(obj, ['a', 'c'])

// => "{"a":1,"c":3}"

可以看到,我们在 replacer 中指定了要序列化 ac 属性,输出结果也是只有这两个属性的值,且不可枚举的 c 属性也被序列化了!守得云开见月明,Error 对象被序列化的方法也就出来了:

const err = new Error('This is an error')

JSON.stringify(err, Object.getOwnPropertyNames(err), 2)

// => 
// "{
//   "stack": "Error: This is an error\n    at <anonymous>:1:13",
//   "message": "This is an error"
// }"

后记

文章本来的标题是“你不知道的 JSON.stringify()”,但是总感觉词不达意,有标题党的嫌疑,遂改成更为实在的现标题。

对于一些常用的函数,其背后也有着许多值得探索的内容,比如这次为了让 JSON.stringify() 去序列化一个 Error,我又复习了一遍 JS 对象属性中 enumerable 的相关知识,才知道这些原本以为很底层的基础知识其实对真实业务也有着巨大的作用。夯实基础,永远都是很重要的。

查看原文

赞 27 收藏 13 评论 6

jrainlau 发布了文章 · 2019-09-05

GraphQL 项目中的前端 mock 方案

image

在使用 GraphQL (以下简称 gql)的前端项目中,往往需要等待后台同学定义好 Schema 并架设好 Playground 以后才能进行联调。如果后台同学阻塞了,前端只能被动等待。如果对于 gql 项目来说也能够和 REST 一样有一套 mock 方案就好了。经过一系列实践,我选择了 mocker-apiApollo 的方案来实现。

mocker-api

mocker-api 是一个基于 node 实现的接口 mock 工具(前身是webpack-api-mocker,依赖于 webpack-dev-server,现在可独立运行)。由于我们的项目大都和 webpack 结合,所以这里仅简单介绍其与 webpack 结合的用法。

在 webpack 的 devServer 配置项中,引入以下内容:

devServer: {
  before (app) {
    require('mocker-api')(app, resolve('./mock/index.js'))
  }
}

这样便完成了 webpack 和 mocker-api 的结合。接下来我们要到 /mock/index.js 里面写逻辑:

// /mock/index.js

module.exports = {
  'POST /api': (req, res) => {
    return res.send('Hello world')
  }
}

此时开启 webpack-dev-server,在页面中使用 POST 方式请求 /api,即可得到内容为 Hello world 的响应。

Apollo

Apollo 是一套完整的 GraphQL 实现方案,支持多种语言的服务端和客户端,在这里我们主要使用 apollo-server 来搭建前端的 gql mock 服务。

/mock 目录下新建 /gql 目录,再往里面分别建立 /resolvers 目录,types 目录和 index.js 入口文件。下面我们以一个”查询书籍信息“的例子来讲述这个 gql mock 服务是怎么做的。

/types 目录下新建 Books.js

const { gql } = require('apollo-server')

module.exports = gql`
"""
书籍
"""
type Book {
  id: ID!
  "标题"
  title: String
  "作者"
  author: String
}

type Query {
  books: [Book]
}

type Mutation {
  addBook(title: String, author: String): [Book]
}
`

接下来,在 /resolvers 目录底下新建 Books.js

const books = [
  {
    id: parseInt(Math.random() * 10000),
    title: 'Harry Potter and the Chamber of Secrets',
    author: 'J.K. Rowling'
  },
  {
    id: parseInt(Math.random() * 10000),
    title: 'Jurassic Park',
    author: 'Michael Crichton'
  }
]

module.exports = {
  query: {
    books: () => books,
  },
  mutation: {
    addBook: (root, book) => {
      book.id = parseInt(Math.random() * 10000)
      books.push(book)
      return books
    }
  }
}

最后在入口文件 index.js 里分别引入上面两个文件:

const { ApolloServer } = require('apollo-server')

const typeDefs = [
  require('./types/Books')
]

const resolvers = {
  Query: {
    ...require('./resolvers/Books').query
  },
  Mutation: {
    ...require('./resolvers/Books').mutation
  }
}

const server = new ApolloServer({ typeDefs, resolvers })

server.listen().then(({ url }) => {
  console.log(`🚀 Apollo server ready at ${url}`);
})

运行 node ./mock/gql/index.js,即可在 localhost:4000 打开 Playground 进行调试了。

image

使用 mocker-api 把请求转发到本地的 Playground

在实际的业务中,gql 接口往往被封装成形如 /api/gql 的形式,和其他的 rest 接口一起供客户端调用。为了让 /api/gql 接口能够被转发到 localhost:4000 的 Playground,我们可以利用 mocker-api 进行转发。

改写 /mock/index.js,为其增加一个 /api/gql 的地址:

const axios = require('axios')

module.exports = {
  'POST /api': (req, res) => {
    return res.send('Hello world')
  },
  'POST /api/gql': async (req, res) => {
    const reqBody = req.body
    const { data: result } = await axios({
      url: 'http://localhost:4000',
      method: 'POST',
      data: reqBody
    }).catch(e => e)
    return res.send({
      data: result.data
    })
  }
}
这里我使用了 axios 往 apollo-server 发起请求。

此时在 webpack-dev-server 所启动的页面中往 /api/gql 发起一个 gql 请求,即可验证接口:

      fetch('/api/gql', {
        method: 'POST',
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ query: 'query GetBooks {  books {    title  }}' })
      })
        .then(res => res.json())
        .then(result => console.log(result))

image

由于 mocker-api 支持 hot reload,所以当我们什么时候不再需要 mock 数据时,直接在 /mock/index.js 中把 'POST /api/gql' 这一段注释掉即可,无需重启 dev server。

至此,GraphQL 项目中的前端 mock 方案大功告成。

查看原文

赞 2 收藏 2 评论 0

jrainlau 发布了文章 · 2019-08-27

来自 Vue 3.0 的 Composition API 尝鲜

image

前段时间,Vue 官方释出了 Composition API RFC 的文档,我也在收到消息的第一时间上手尝鲜。

虽然 Vue 3.0 尚未发布,但是其处于 RFC 阶段的 Composition API 已经可以通过插件 @vue/composition-api 进行体验了。接下来的内容我将以构建一个 TODO LIST 应用来体验 Composition API 的用法。

本文示例的代码:https://github.com/jrainlau/v...

一、Vue 2.x 方式构建应用。

这个 TODO LIST 应用非常简单,仅有一个输入框、一个状态切换器、以及 TODO 列表构成:

image

大家也可以在这里体验。

借助 vue-cli 初始化项目以后,我们的项目结构如下(仅讨论 /src 目录):

.
├── App.vue
├── components
│   ├── Inputer.vue
│   ├── Status.vue
│   └── TodoList.vue
└── main.js

/components 里文件的命名不难发现,三个组件对应了 TODO LIST 应用的输入框、状态切换器,以及 TODO 列表。这三个组件的代码都非常简单就不展开讨论了,此处只讨论核心的 App.vue 的逻辑。

  • App.vue
<template>
  <div class="main">
    <Inputer @submit="submit" />
    <Status @change="onStatusChanged" />
    <TodoList
      :list="onShowList"
      @toggle="toggleStatus"
      @delete="onItemDelete"
    />
  </div>
</template>

<script>
import Inputer from './components/Inputer'
import TodoList from './components/TodoList'
import Status from './components/Status'

export default {
  components: {
    Status,
    Inputer,
    TodoList
  },

  data () {
    return {
      todoList: [],
      showingStatus: 'all'
    }
  },
  computed: {
    onShowList () {
      if (this.showingStatus === 'all') {
        return this.todoList
      } else if (this.showingStatus === 'completed') {
        return this.todoList.filter(({ completed }) => completed)
      } else if (this.showingStatus === 'uncompleted') {
        return this.todoList.filter(({ completed }) => !completed)
      }
    }
  },
  methods: {
    submit (content) {
      this.todoList.push({
        completed: false,
        content,
        id: parseInt(Math.random(0, 1) * 100000)
      })
    },
    onStatusChanged (status) {
      this.showingStatus = status
    },
    toggleStatus ({ isChecked, id }) {
      this.todoList.forEach(item => {
        if (item.id === id) {
          item.completed = isChecked
        }
      })
    },
    onItemDelete (id) {
      let index = 0
      this.todoList.forEach((item, i) => {
        if (item.id === id) {
          index = i
        }
      })
      this.todoList.splice(index, 1)
    }
  }
}
</script>

在上述的代码逻辑中,我们使用 todoList 数组存放列表数据,用 onShowList 根据状态条件 showingStatus 的不同而展示不同的列表。在 methods 对象中定义了添加项目、切换项目状态、删除项目的方法。总体来说还是非常直观简单的。

按照 Vue 的官方说法,2.x 的写法属于 Options-API 风格,是基于配置的方式声明逻辑的。而接下来我们将使用 Composition-API 风格重构上面的逻辑。

二、使用 Composition-API 风格重构逻辑

下载了 @vue/composition-api 插件以后,按照文档在 main.js 引用便开启了 Composition API 的能力。

  • main.js
import Vue from 'vue'
import App from './App.vue'
import VueCompositionApi from '@vue/composition-api'

Vue.config.productionTip = false
Vue.use(VueCompositionApi)

new Vue({
  render: h => h(App),
}).$mount('#app')

回到 App.vue,从 @vue/composition-api 插件引入 { reactive, computed, toRefs } 三个函数:

import { reactive, computed, toRefs } from '@vue/composition-api'

仅保留 components: { ... } 选项,删除其他的,然后写入 setup() 函数:

export default {
  components: { ... },
  setup () {}
}

接下来,我们将会在 setup() 函数里面重写之前的逻辑。

首先定义数据

为了让数据具备“响应式”的能力,我们需要使用 reactive() 或者 ref() 函数来对其进行包装,关于这两个函数的差异,会在后续的章节里面阐述,现在我们先使用 reactive() 来进行。

setup() 函数里,我们定义一个响应式的 data 对象,类似于 2.x 风格下的 data() 配置项。

setup () {
    const data = reactive({
      todoList: [],
      showingStatus: 'all',
      onShowList: computed(() => {
        if (data.showingStatus === 'all') {
          return data.todoList
        } else if (data.showingStatus === 'completed') {
          return data.todoList.filter(({ completed }) => completed)
        } else if (data.showingStatus === 'uncompleted') {
          return data.todoList.filter(({ completed }) => !completed)
        }
      })
    })
}

其中计算属性 onShowList 经过了 computed() 函数的包装,使得它可以根据其依赖的数据的变化而变化。

接下来定义方法

setup() 函数里面,对之前的几个操作选项的方法稍加修改即可直接使用:

    function submit (content) {
      data.todoList.push({
        completed: false,
        content,
        id: parseInt(Math.random(0, 1) * 100000)
      })
    }
    function onStatusChanged (status) {
      data.showingStatus = status
    }
    function toggleStatus ({ isChecked, id }) {
      data.todoList.forEach(item => {
        if (item.id === id) {
          item.completed = isChecked
        }
      })
    }
    function onItemDelete (id) {
      let index = 0
      data.todoList.forEach((item, i) => {
        if (item.id === id) {
          index = i
        }
      })
      data.todoList.splice(index, 1)
    }

与在 methods: {} 对象中定义的形式所不同的地方是,在 setup() 里的方法不能通过 this 来访问实例上的数据,而是通过直接读取 data 来访问。

最后,把刚刚定义好的数据和方法都返回出去即可:

    return {
      ...toRefs(data),
      submit,
      onStatusChanged,
      toggleStatus,
      onItemDelete,
    }

这里使用了 toRefs()data 对象包装了一下,是为了让它的数据保持“响应式”的,这里面的原委会在后续章节展开。

重构完成后,发现其运行的结果和之前的完全一致,证明 Composition API 是可以正确运行的。接下来我们来聊聊 reactive()ref() 的问题。

三、响应式数据

我们知道 Vue 的其中一个卖点,就是其强大的响应式系统。无论是哪个版本,这个核心功能都贯穿始终。而说到响应式系统,往往离不开响应式数据,这也是被大家所津津乐道的话题。

回顾一下,在2.x版本中 Vue 使用了 Object.defineProperty() 方法改写了一个对象,在它的 getter 和 setter 里面埋入了响应式系统相关的逻辑,使得一个对象被修改时能够触发对应的逻辑。在即将到来的 3.0 版本中,Vue 将会使用 Proxy 来完成这里的功能。为了体验所谓的“响应式对象”,我们可以直接通过 Vue 提供的一个 API Vue.observable() 来实现:

const state = Vue.observable({ count: 0 })

const Demo = {
  render(h) {
    return h('button', {
      on: { click: () => { state.count++ }}
    }, `count is: ${state.count}`)
  }
}
上述代码引用自官方文档

从代码可以看出,通过 Vue.observable() 封装的 state,已经具备了响应式的特性,当按钮被点击的时候,它里面的 count 值会改变,改变的同时会引起视图层的更新。

回到 Composition API,它的 reactive()ref() 函数也是为了实现类似的功能,而 @vue/composition-api 插件的核心也是来自 Vue.observable()

function observe<T>(obj: T): T {
  const Vue = getCurrentVue();
  let observed: T;
  if (Vue.observable) {
    observed = Vue.observable(obj);
  } else {
    const vm = createComponentInstance(Vue, {
      data: {
        $$state: obj,
      },
    });
    observed = vm._data.$$state;
  }

  return observed;
}
节选自插件源码

在理解了 reactive()ref() 的目的之后,我们就可以去分析它们的区别了。

首先我们来看两段代码:

// style 1: separate variables
let x = 0
let y = 0

function updatePosition(e) {
  x = e.pageX
  y = e.pageY
}

// --- compared to ---

// style 2: single object
const pos = {
  x: 0,
  y: 0
}

function updatePosition(e) {
  pos.x = e.pageX
  pos.y = e.pageY
}

假设 xy 都是需要具备“响应式”能力的数据,那么 ref() 就相当于第一种风格,单独地为某个数据提供响应式能力;而 reactive() 则相当于第二种风格,给一整个对象赋予响应式能力。

但是在具体的用法上,通过 reactive() 包装的对象会有一个坑。如果想要保持对象内容的响应式能力,在 return 的时候必须把整个 reactive() 对象返回出去,同时在引用的时候也必须对整个对象进行引用而无法解构,否则这个对象内容的响应式能力将会丢失。这么说起来有点绕,可以看看官网的例子加深理解:

// composition function
function useMousePosition() {
  const pos = reactive({
    x: 0,
    y: 0
  })

  // ...
  return pos
}

// consuming component
export default {
  setup() {
    // reactivity lost!
    const { x, y } = useMousePosition()
    return {
      x,
      y
    }

    // reactivity lost!
    return {
      ...useMousePosition()
    }

    // this is the only way to retain reactivity.
    // you must return `pos` as-is and reference x and y as `pos.x` and `pos.y`
    // in the template.
    return {
      pos: useMousePosition()
    }
  }
}

举一个不太恰当的例子。“对象的特性”是赋予给整个“对象”的,它里面的内容如果也想要拥有这部分特性,只能和这个对象捆绑在一块,而不能单独拎出来。

但是在具体的业务中,如果无法使用解构取出 reactive() 对象的值,每次都需要通过 . 操作符访问它里面的属性会是非常麻烦的,所以官方提供了 toRefs() 函数来为我们填好这个坑。只要使用 toRefs()reactive() 对象包装一下,就能够通过解构单独使用它里面的内容了,而此时的内容也依然维持着响应式的特性。

至于何时使用 reactive()ref(),都是按照具体的业务逻辑来选择。对于我个人来说,会更倾向于使用 reactive() 搭配 toRefs() 来使用,因为经过 ref() 封装的数据必须通过 .value 才能访问到里面的值,写法上要注意的地方相对更多一些。

四、Composition API 的优势及扩展

Vue 其中一个被人诟病得很严重的问题就是逻辑复用。随着项目越发的复杂,可以抽象出来被复用的逻辑也越发的多。但是 Vue 在 2.x 阶段只能通过 mixins 来解决(当然也可以非常绕地实现 HOC,这里不再展开)。mixins 只是简单地把代码逻辑进行合并,如果需要对逻辑进行追踪将会是一个非常痛苦的过程,因为繁杂的业务逻辑里面往往很难一眼看出哪些数据或方法是来自 mixins 的,哪些又是来自当前组件的。

另外一点则是对 TypsScript 的支持。为了更好地进行类型推断,虽然 2.x 也有使用 Class 风格的 ts 实现方案,但其冗长繁杂和依赖不稳定的 decorator 的写法,并非一个好的解决方案。受到 React Hooks 的启发,Vue Composition API 以函数组合的方式完成逻辑,天生就适合搭配 TypeScript 使用。

至于 Options API 和 Composition API 孰优孰劣的问题,在本文所展示的例子中其实是比较难区分的,原因是这个例子的逻辑实在是太过简单。但是如果深入思考的话不难发现,如果项目足够复杂,Composition API 能够很好地把逻辑抽离出来,每个组件的 setup() 函数所返回的值都能够方便地被追踪(比如在 VSCode 里按着 cmd 点击变量名即可跳转到其定义的地方)。这样的能力在维护大型项目或者多人协作项目的时候会非常有用,通用的逻辑也可以更细粒度地共享出去。

关于 Composition API 的设计理念和优势可以参考官网的 Motivation 章节

如果脑洞再开大一点,Composition API 可能还有更酷的玩法。

  • 对于一些第三方组件库(如 element-ui),除了可以提供包含了样式、结构和逻辑的组件之外,还可以把部分逻辑以 Composition API 的方式提供出来,其可定制化和玩法将会更加丰富。
  • reactive() 方法可以把一个对象变得响应式,搭配 watch() 方法可以很方便地处理 side effects:

    import { reactive, watch } from 'vue'
    
    const state = reactive({
      count: 0
    })
    
    watch(() => {
      document.body.innerHTML = `count is ${state.count}`
    })

    上述例子中,当响应式的 state.count 被修改以后,会触发 watch() 函数里面的回调。基于此,也许我们可以利用这个特性去处理其他平台的视图更新问题。微信小程序开发框架 mpvue 就是通过魔改 Vue 的源码来实现小程序视图的数据绑定及更新的,如果拥有了 Composition API,也许我们就可以通过 reactive()watch() 等方法来实现类似的功能,此时 Vue 将会是位于数据视图中间的一层,数据的绑定放在 reactive(),而视图的更新则统一放在 watch() 当中进行。

五、小结

本文通过一个 TODO LIST 应用,按照官网的指导完成一次对 Composition API 的尝鲜式探索,学习了新的 API 的用法并讨论了当中的一些设计理念,分析了当中的一些问题,最后脑洞大开对立面的用法进行了探索。由于相关资料较少且 Composition API 仍在 RFC 阶段,所以文章当中可能会有难以避免的谬误,如果有任何的意见和看法都欢迎和我交流。

查看原文

赞 27 收藏 16 评论 1

jrainlau 发布了文章 · 2019-07-07

项目中使用 TypeScript 的一些感悟

image

上周发布了一款名为 Smartour 的工具,是完全采用 TypeScript (以下简称 ts)来开发的。抛开以前做业务的时候的不完全使用,这次实践可以算是我第一次真正意义上的使用 ts。由于写法上的不同,以及对不熟悉事物的新鲜感,在这次项目开发的过程中着实有着许多感悟,于是打算写篇小东西好好记录下来。

TS 能让人养成“先思考后动手”的好习惯

在以往的开发过程中,我的习惯总是“先想好一个大概,然后边做边想再边改”。这样的好处是动作比较快,顺利的时候效率会很高,但更多的时候是不断地推翻自己先前的想法,相信不少的人也有跟我类似的体会。

而使用 ts,可以在一定程度上减少这个问题。众所周知 ts 是强类型的语言,这也意味着它能有效制约开发者在开发过程中“随心所欲”的程度。就以定义一个函数的参数为例,可以看看我在写 js 和 ts 的思考方式上有什么不同。

写 js 的时候,我的思考过程是这样的。

  1. 首先这个参数是一个对象,这个对象的属性 el 是一个 css 选择器;而对象的属性 keyNodes 是一个数组,里面的元素是一系列的 keyNode
  2. 这个所谓的 keyNode 也是一个对象,它也包含了一个 css 选择器属性 el,以及一个绑定在 dom 元素上的事件参数 event
  3. 我会把这个参数对象以注释的形式写下来,以便记住它的具体定义。
/**
{
    el: '#demo-id',
    keyNodes: [{
      el: '.item-1',
      event (e) { console.log(e) }
    }]
}
 */
  1. 以后任何地方要用到这个参数,我都要手动保证参数的结构要和这个注释保持一致。

而换成 ts 的写法以后,我的思路是这样的:

  1. 首先这个参数是一个对象,这个对象的属性 el 是一个 css 选择器;而对象的属性 keyNodes 是一个数组,里面的元素是一系列的 keyNode
  2. 然后我会通过 interface 把它给定义好:
interface HightlightElement {
    el: string,
    keyNodes: Array<KeyNode>
}
interface KeyNode {
    el: string,
    event: EventListener
}
  1. 在需要用到这个参数的时候,只需要在定义形参的时候传入这个 interface 即可。万一参数结构或内容的类型有误,VScode 编辑器都会立刻给予提示,省去了手动检查的麻烦。
someFunction (param: HightlightElement) { ... }

可以看到,在写 js 的时候更多的是“自己和自己约定,自己判断是否遵守了约定”,而 ts 则是“自己和自己约定以后,由第三方(编辑器)去判断是否遵守了约定”。这样的好处是除了老生常谈的减少错误之外,更多的则是对思维上的良性约束。这种良性约束能够让我们在思考的阶段就定义好接下来要做的一系列事情,在操作的过程中如果发现任何问题也能够在第一时间溯源回最初思考的起点,排查问题的时候会更加高效。

TS 拥有自成文档的特性

在写 js 的时候,我们依赖注释去判断某个变量或参数的类型、结构和作用。如果没有了注释,只能通过阅读源码和不断调试去搞清楚当中的细节。许多人在接手他人项目的时候都会有这么一个经历:“为什么不写注释!这个函数写的啥!这参数又是啥!”没有注释的 js 代码是让人崩溃的,但是写注释不仅需要时间,更考验一个人的概括能力。说了等于没说甚至误导性的注释,也是足够让人崩溃。

在 ts 中,除了注释以外我们还有另外一个选择,就是查看某个变量或参数所对应的 interface 接口定义。在 interface 中我们可以很直观地看到参数的结构,内部属性的类型,是否为可选等详细信息。再加上VScode 的智能提示及跳转,不管是查看他人的代码还是维护一个历史项目,都能更加方便和规范——毕竟写接口往往比写注释要顺手,看接口往往比猜代码要稳妥。

说到自成文档的特性,我也联想到了另外一个热门技术 GraphQL。借助 GraphQL 社区配套的一系列工具,调用方在调用接口的时候就能直接读到接口的标准定义;而接口的开发者也不需要额外编写文档,在定义接口的时候其实就相当于把文档也写好了。

自成文档的特性对于多人维护的项目来说是非常有用的,它能够大大降低项目当中沟通和理解的成本。但是这句话也有一个前提,就是开发者要遵守并合理工具当中的约束规范,如果一个接口的任何参数类型都是 any ,那么也就失去了使用 ts 的意义。

TS 能够降低搭建环境的时间成本

为了同时使用 js 新颖的特性以及兼容陈旧的浏览器,我们往往会借助一系列的工具去搭建一套开发环境。也许我们已经习惯了 webpack + babel 的开发方式,可是又有谁能够保证自己在不看文档的情况下能够自己去搭一套呢?且不说这些工具各有着复杂的文档,就算好不容易把环境搭好了,还会发现有着更多“最佳实践”。改来改去花了一天时间,才终于算是完成。

作为 js 的超集,我们可以在 ts 中放心使用 js 的各种高级能力。由于自带命令行工具,我们不再需要去研究 babel 或者各种 preset-env 插件,只需要指定需要构建的版本,ts 命令行工具就会自动为我们生成对应版本的 js。

当然这并不是说有了 ts 就能够完全抛弃构建工具了,在构建复杂应用(如包含各种静态资源,跨格式文件引用)场景的情况下还是离不开构建工具的,且在未来很长一段时间都会维持这种状况。但是秉承着“多一事不如少一事”的原则,只要能够减少哪怕是一个工具的使用,对开发者来说都是有好处的,毕竟我们都期待着某一个能够只管代码不管环境的日子。

尾声

由于不是 ts 的资深玩家,以上的碎碎念都是作为一个初学者个人的新鲜感。在工作的这些日子里,也深刻体会到永远没有百分百理想化的东西。ts 固然是好,但也需要辩证地看待它。我们是否真的需要 ts?它是否真的能够提高我们的生产力?它是否真的如他人描述般理想?这些问题都需要经过实践才能回答。说到底 ts 只是一个工具,什么时候用它,怎么用它,还是取决于具体的场合。一味地尬吹或者否认其他的东西,只能说明思想还是太狭隘了。

查看原文

赞 30 收藏 16 评论 2

jrainlau 发布了文章 · 2019-07-02

Smartour——让网页导览变得更简单

Jul-04-2019 18-07-06

在遇到网页内容有着较大调整的时候,往往需要一个导览功能去告诉用户,某某功能已经调整到另外一个位置。比较常规的办法是添加一个蒙层,高亮显示被调整的区域,然后通过文字介绍去完成引导。我们把这个功能称为“导览”,而 Smartour 则把这个导览的功能抽离出来,提供了一个开箱即用的解决方案。

项目地址:https://github.com/jrainlau/s...
官方示例:https://jrainlau.github.io/sm...

Install

Smartour 被构建成了 umdes module 模块,允许用户通过不同的方式引入。

npm install smartour
/* ES Modules */
import Smartour from 'smartour/dist/index.esm.js'
/* CommandJS */
const Smartour = require('smartour')
/* <script> */
<script data-original="smartour/dist/index.js"></script>

基本用法

Smartour 提供了一个非常简单的 API focus(), 这是高亮一个元素最简单的方式。

let tour = new Smartour()

tour.focus({
  el: '#basic-usage'
})

插槽 Slot

插槽 slot 是用于为高亮元素提供描述的 html 字符串。

纯字符串:

let tour = new Smartour()

tour.focus({
  el: '#pure-string',
  slot: 'This is a pure string'
})

Html 字符串

let tour = new Smartour()

tour.focus({
  el: '#html-string',
  slot: `
    <div>
      <p>This is a html string</p>
    </div>
  `
})

插槽位置

插槽的位置可以选择4个不同的方向: top, right, left, bottom.

设置 options.slotPosition 属性即可覆盖默认的 top 位置。

let tour = new Smartour()

tour.focus({
  el: '#slot-positions',
  slot: `top`,
  options: {
    slotPosition: 'top' // 默认为 `top`
  }
})

插槽事件

插槽所定义的元素也可以绑定事件。我们通过 keyNodes 属性来为插槽元素绑定事件。

keyNodes 是内容为一系列 keyNode 的数组。 属性 keyNode.el 是一个 css 选择器,而 keyNode.event 属性则是对应元素所绑定的事件。

let tour = new Smartour()

tour.focus({
  el: '.slot-events-demo',
  options: {
    slotPosition: 'right'
  },
  slot: `

      Click here to occur an alert event
    </button>

      Click here to occur an alert event
    </button>
  `,
  keyNodes: [{
    el: '.occur-1',
    event: () => { alert('Event!!') }
  }, {
    el: '.occur-2',
    event: () => { alert('Another event!!') }
  }]
})

Queue

有的时候页面需要不止一个导览。Smartour 允许你把一系列的导览通过 .queue() 放在一起,然后挨个挨个地展示它们。

举个例子:

let tour = new Smartour()

tour
  .queue([{
    el: '.li-1',
    options: {
      layerEvent: tour.next.bind(tour)
    },
    slot: 'This is the 1st line.'
  }, {
    el: '.li-2',
    options: {
      layerEvent: tour.next.bind(tour)
    },
    slot: 'This is the 2nd line.'
  }, {
    el: '.li-3',
    options: {
      layerEvent: tour.next.bind(tour)
    },
    slot: 'This is the 3rd line.'
  }])
  .run() // 别忘了调用 `run()` 方法去展示第一个导览

选项

Smartour 是一个构建函数,它接收一个 options 参数去覆盖其默认选项

让我们看看它的默认选项是什么样子的:

{
  prefix: 'smartour', // class 前缀
  padding: 5, // 高亮区域内边距
  maskColor: 'rgba(0, 0, 0, .5)', // 带透明值的遮罩层颜色
  animate: true, // 是否使用动画
  slotPosition: 'top' // 默认的插槽位置
  layerEvent: smartour.over // 遮罩层点击事件,默认为结束导览
}

APIs

除了 .focus().queue().run() API 以外,Smartour 还提供了另外三个 API 专门用于控制导览的展示。

  • .next():展示下一个导览(只能配合 .queue() 使用)。
  • .prev():展示上一个导览(只能配合 .queue() 使用)。
  • .over():结束全部导览。

Smartour 的原理

Smartour 通过 element.getBoundingClientRect() api 来获取目标元素的宽高和位置信息,然后放置一个带着 box-shadow 样式的元素在其之上作为高亮区域。

由于点击事件无法再 box-shadow 的区域里触发,所以 Smartour 还放置了一个全屏透明的遮罩层用于绑定 layerEvent 事件。

高亮区域和插槽都被设置为 absolute。当页面滚动时,document.documentElement.scrollTop 或者 document.documentElement.scrollLeft 的值就会变化,然后 Smartour 会实时修正它的位置信息。

证书

MIT

查看原文

赞 76 收藏 55 评论 0

jrainlau 发布了文章 · 2019-06-19

在小程序中实现 Mixins 方案

原文来自我的博客:https://jrainlau.github.io/#/...

image

在原生开发小程序的过程中,发现有多个页面都使用了几乎完全一样的逻辑。由于小程序官方并没有提供 Mixins 这种代码复用机制,所以只能采用非常不优雅的复制粘贴的方式去“复用”代码。随着功能越来越复杂,靠复制粘贴来维护代码显然不科学,于是便寻思着如何在小程序里面实现 Mixins。

什么是 Mixins

Mixins 直译过来是“混入”的意思,顾名思义就是把可复用的代码混入当前的代码里面。熟悉 VueJS 的同学应该清楚,它提供了更强大了代码复用能力,解耦了重复的模块,让系统维护更加方便优雅。

先看看在 VueJS 中是怎么使用 Mixins 的。

// define a mixin object
var myMixin = {
  created: function () {
    this.hello()
  },
  methods: {
    hello: function () {
      console.log('hello from mixin!')
    }
  }
}

// define a component that uses this mixin
var Component = Vue.extend({
  mixins: [myMixin]
})

var component = new Component() // => "hello from mixin!"

在上述的代码中,首先定义了一个名为 myMixin 的对象,里面定义了一些生命周期函数和方法。接着在一个新建的组件里面直接通过 mixins: [myMixin] 的方式注入,此时新建的组件便获得了来自 myMixin 的方法了。

明白了什么是 Mixins 以后,便可开始着手在小程序里面实现了。

Mixins 的机制

Mixins 也有一些小小的细节需要注意的,就是关于生命周期事件的执行顺序。在上一节的例子中,我们在 myMixin 里定义了一个 created() 方法,这是 VueJS 里面的一个生命周期事件。如果我们在新建组件 Component 里面也定义一个 created() 方法,那么执行结果会是如何呢?

var Component = Vue.extend({
  mixins: [myMixin],
  created: function () {
    console.log('hello from Component!')
  }
})

var component = new Component()

// =>
// Hello from mixin!
// Hello from Component!

可以看运行结果是先输出了来自 Mixin 的 log,再输出来自组件的 log。

除了生命周期函数以外,再看看对象属性的混入结果:

// define a mixin object
const myMixin = {
  data () {
    return {
      mixinData: 'data from mixin'
    }
  }
}

// define a component that uses this mixin
var Component = Vue.extend({
  mixins: [myMixin],
  data () {
    return {
      componentData: 'data from component'
    }
  },
  mounted () {
    console.log(this.$data)
  }
})

var component = new Component()

image

在 VueJS 中,会把来自 Mixins 和组件的对象属性当中的内容(如 data, methods等)混合,以确保两边的数据都同时存在。

经过上述的验证,我们可以得到 VueJS 中关于 Mixins 运行机制的结论:

  1. 生命周期属性,会优先执行来自 Mixins 当中的,后执行来自组件当中的。
  2. 对象类型属性,来自 Mixins 和来自组件中的会共存。

但是在小程序中,这套机制会和 VueJS 的有一点区别。在小程序中,自定义的方法是直接定义在 Page 的属性当中的,既不属于生命周期类型属性,也不属于对象类型属性。为了不引入奇怪的问题,我们为小程序的 Mixins 运行机制多加一条:

  1. 小程序中的自定义方法,优先级为 Page > Mixins,即 Page 中的自定义方法会覆盖 Mixins 当中的。

代码实现

在小程序中,每个页面都由 Page(options) 函数定义,而 Mixins 则作用于这个函数当中的 options 对象。因此我们实现 Mixins 的思路就有了——劫持并改写 Page 函数,最后再重新把它释放出来。

新建一个 mixins.js 文件:

// 保存原生的 Page 函数
const originPage = Page

Page = (options) => {
  const mixins = options.mixins
  // mixins 必须为数组
  if (Array.isArray(mixins)) {
    delete options.mixins
    // mixins 注入并执行相应逻辑
    options = merge(mixins, options)
  }
  // 释放原生 Page 函数
  originPage(options)
}

原理很简单,关键的地方在于 merge() 函数。merge 函数即为小程序 Mixins 运行机制的具体实现,完全按照上一节总结的三条结论来进行。

// 定义小程序内置的属性/方法
const originProperties = ['data', 'properties', 'options']
const originMethods = ['onLoad', 'onReady', 'onShow', 'onHide', 'onUnload', 'onPullDownRefresh', 'onReachBottom', 'onShareAppMessage', 'onPageScroll', 'onTabItemTap']

function merge (mixins, options) {
  mixins.forEach((mixin) => {
    if (Object.prototype.toString.call(mixin) !== '[object Object]') {
      throw new Error('mixin 类型必须为对象!')
    }
    // 遍历 mixin 里面的所有属性
    for (let [key, value] of Object.entries(mixin)) {
      if (originProperties.includes(key)) {
        // 内置对象属性混入
        options[key] = { ...value, ...options[key] }
      } else if (originMethods.includes(key)) {
        // 内置方法属性混入,优先执行混入的部分
        const originFunc = options[key]
        options[key] = function (...args) {
          value.call(this, ...args)
          return originFunc && originFunc.call(this, ...args)
        }
      } else {
        // 自定义方法混入
        options = { ...mixin, ...options }
      }
    }
  })
  return options
}

Mixins 使用

  1. 在小程序的 app.js 里引入 mixins.js

    require('./mixins.js')
  2. 撰写一个 myMixin.js

    module.exports = {
      data: { someData: 'myMixin' },
      onShow () { console.log('Log from mixin!') }
    }
  3. page/index/index.js 中使用

    Page({
      mixins: [require('../../myMixin.js')]
    })

image

大功告成!此时小程序已经具备 Mixins 的能力,对于代码解耦与复用来说将会更加方便。

查看原文

赞 44 收藏 25 评论 4

jrainlau 发布了文章 · 2019-05-30

详解 Github App 的玩法

image

之前在使用 Github issues 搭建博客平台的时候,研究过一番如何取得 Github 授权并调用 API 的办法。后来选择了较简单的账号密码和 Token 的方法。但是有读者反馈这样的操作依然稍显麻烦,且在第三方的页面输入账号密码总感觉不安全。后来经过研究,总算找到了 Github App 这种更为优雅的办法。

什么是 Github App

要回答这个问题,可以直接套用官方文档的说法:

GitHub Apps are first-class actors within GitHub. A GitHub App acts on its own behalf, taking actions via the API directly using its own identity, which means you don't need to maintain a bot or service account as a separate user.

简单翻译一下,就是 Github App 可以通过 Github 提供的认证信息去调用 Github API。

细心的读者会发现,Github 还提供了一个叫做“OAuth App”的东西,它的使用方式和 Github App 非常类似,最大的不同点是 OAuth App 所获取的权限都是固定只读的,用户只能读取固定的数据而不能修改数据;而 Github App 几乎可以获取Github提供的所有功能权限,且所获取的权限可以被设定为“只读”,“可读可写”和“禁止访问”,对于权限的授权粒度会更细。

image

获取了对某些操作的权限之后,我们就可以使用这些权限去搭建一个独立的 App,比如一个第三方的 Github 客户端等等,这也是 Github App 的实用之处。

第三方登录的原理

前文提到,Github App 可以免去用户在第三方页面输入账号密码或者 Token 的操作而完成授权,那么它是怎么做到的呢?其实说白了,它也是一种 OAuth 登录的方式,只不过把获取 Token 的方式从“用户输入”变成“由 Github 提供”。

下面介绍这种登录方式的流程:

  1. A 网站跳转到 Github 的授权页面。
  2. Github 授权页面询问用户:“是否允许A网站获取下列权限”,用户点击“允许”,取得授权码。
  3. Github 授权页面重定向回 A 网站,同时在URL 上带上授权码。
  4. A 网站通过 URL 上的授权码往 Github 取回 Token。
  5. A 网站使用这个 Token 去调用 Github API。

要完成上述的流程,首先必须先注册一个 Github App。

注册 Github App

进入 Github主页,点击用户头像,找到 Setting/Developer settings/Github Apps,然后点击“New Github App”,即可进入编辑界面:

image

依次填入名称(此处为 SOMEONE:BLOG )、描述、主页 URL 以后,关键要在User authorization callback URL填入获取授权后的回调地址,然后在Permissions里面设置一些需要用到的 API 读写能力。如果你希望这个 APP 只能自己用,那么使用默认的Only on this account,否则就选择Any account,最后点击Create Github App即可。

操作成功后,就可以看到这个 APP 的信息了:

image

其中的 Client ID 和 Client secret 就是这个应用的身份识别码,需要记下来。

Github App 注册完毕,接下来就需要第三方网站使用这个 APP 的 Client ID 去找 Github 要授权码了。

获取授权码

第三方网站要获取授权码,只需要让页面跳转到 Github 授权页即可,其中需要在 URL 中携带两个参数,分别是 Client ID 和 Redirect URL。

const CLIENT_ID = 'app 的 client id'
const REDIRECT_URL = 'app 的 redirect_url'

location.href = `https://github.com/login/oauth/authorize?` + 
`client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URL}`

跳转后,Github 会询问用户是否允许这个 APP 获取某些权限:
image

用户确定后,会带着授权码重定向到给定的回调地址:
image

这时候,第三方页面(这里是 localhost:8080)已经拿到了授权码,接下来就需要凭借这个授权码以及 APP 的 Client ID 和 Client secret 去兑换 Token 了。

兑换 Token

兑换 Token 的代码如下:

router.post('/oauth', async function (ctx, next) {
  const { clientID = CLIENT_ID, clientSecret = CLIENT_SECRET, code } = ctx.request.body
  const { status, data } = await axios({
    method: 'post',
    url: 'http://github.com/login/oauth/access_token?' +
    `client_id=${clientID}&` +
    `client_secret=${clientSecret}&` +
    `code=${code}`,
    headers: {
      accept: 'application/json'
    }
  }).catch(e => e.response)
  ctx.body = { status, data }
})

由于跨域限制,所以这部分的代码必须通过服务端实现,换句话说,A 网站拿到授权码以后,需要发往这个服务端,由服务端获取 Token 后再重新返回给 A 网站。

A 网站拿到服务端返回的 Token 以后,就可以通过设置 Header 的方式在调用 Github API 的时候使用了:

'Authorization': `Bearer ${Token}`

image

image

到目前为止,基本已经 OK 了,但还有一个很大的问题,就是目前的 Token 所拿到的数据都是“只读”的,并不能对某个 Github 仓库进行任何提交或修改的操作——这是因为此 Github APP 还未被仓库所安装,这也是和 OAuth APP 最大的不同。

安装 Github APP

以我的博客平台 jrainlau.github.io 为例,如果希望用户能够通过 API 对某条 issue 发起评论等操做,我需要在这个仓库里安装我的 Github APP:

进入 Github APP 编辑页 Setting/Developer settings/Github Apps/SOMEONE:BLOG,找到左侧的 Install App,然后选择你的账户去安装:

image

你可以选择账户下的所有仓库或者仅某个仓库去使用这个 APP。点击授权以后,Github APP 安装完毕。此时通过授权的仓库都可以被用户通过 API 进行读写操作了。

在博客平台里,通过这个 APP 评论的用户,其外观上的体现也会标注来自 Github APP:

image

参考资料

查看原文

赞 14 收藏 10 评论 1

jrainlau 发布了文章 · 2019-05-22

为什么我选择用 Github issues 来写博客

image

对于爱写东西的人来说,挑一个合适的博客平台是非常重要的。而作为一个 Web 开发者,我们肯定都希望能够拥有一个高度定制化的博客平台,用以展示我们独一无二的个性以及记录长久以来的学习工作等。与此同时,我们也希望这个平台可以让我们方便地发布内容,提供完整的点赞、留言等操作。在经历过 Hexo,Wordpress,自行搭建服务等一系列尝试以后,我最后选择了以 Github issues 来作为我的博客平台。

博客的基本能力

对于一个合格的博客平台来说,它主要提供了下列几种能力:

  1. 个人介绍
    对于个人博客来说,它首先要支持展示博主的个人介绍。这个个人介绍里面可能包括了头像、昵称、联系方式等基本内容,能够让读者能够对这个博客的主人有一个基本的认识。
  2. 文章的撰写与展示
    对一个博客来说,最重要的就是它的内容,也就是里面的文章。一个好用的博客平台应该具备方便的撰写文章的能力,让够让用户毫无负担地撰写、编辑自己的文章。此外,还必须能够文章的信息,比如展示标题、节选、封面,创建/修改时间,评论点赞数等等。
  3. 归档能力
    一篇文章的撰写时间、内容标签/分类等都是不同的,如何按照不同的要求对这些文章进行归档整理,也是考验博客平台的能力之一。再者,当文章数量较多的时候,添加一个搜索的功能也能大大方便读者对博客的浏览。
  4. 博主与读者互动的能力
    仅仅只有博主一个人自嗨可能难以激发写作的动力,如果博客能够提供博主与读者互动的能力,将能有效激励博主持续创作,更能提升文章的传播度——点赞和评论功能则是互动能力中最重要的功能之一。

经过上面的几个点,基本可以知道一个博客平台,其主要功能就是“展示自己,沟通外界”。在满足这个基础的前提下,它也应该具备方便操作,高度定制化的特点。

为什么不选择其他方案

image

在文章的开头我有提到,我曾经尝试过用 Wordpress,Hexo,自行搭建服务等途径去尝试维护博客。但这些尝试的结果均不合我意,最后无疾而终。归根结底,就是不够自由和方便。

举个例子,Wordpress 和 Hexo 都具备搭建一个主题漂亮、功能齐全的博客的能力,但是这些都必须要在它们所制定的规则下进行。如果我想 DIY 一个主题,或者加入任何我想要的新能力,都必须仔细翻阅它们的文档,找到对应的规则再尝试去实现,可谓是戴着镣铐跳舞。除此之外,要发布新的文章,动辄就要在本地跑命令行,实在是非常不优雅。更有甚者,如果希望为文章添加评论功能,还要费一大番周折,想必体验过的人都懂。

至于自行搭建服务,可谓是既自由又方便,想要任何功能都可以自己实现。但这种方案最大的缺点是成本较高。对于人力成本来说,服务器数据库配置、域名、备案等一系列操作非常烦人,甚至还要考虑告警、负载、宕机等一堆的运维问题。折腾多了,也没什么心思往里面写文章。对于金钱成本来说,买域名,买服务器也是一笔花销,尤其是当我们某段时间文章产出特别少的时候,总觉得白养了一台服务器……

选择 Github issues

首先是 Github,然后才是 issues。

作为全球最大的代码托管平台,又刚刚被微软收入麾下,其可靠程度是非常高的,基本不用担心存放在里面的数据会丢失(想想看国内说没就没的网易博客,百度贴吧等)。

在 Github 上我们可以精心编辑自己的账户信息,包括头像、昵称、邮箱、工作单位等等。

Github issues 提供了非常方便快捷的编辑能力,尤其是贴图。它支持通过拖拽、粘贴、选择的方式上传图片,图片会存放在 https://user-images.githubuse... 这个地方,且支持外链——这也意味着我们可以很方便地把 issue 的内容转载到其他的平台。

在 Github issues 里面,可以为某条 issue 添加点赞、爱心等互动标签(Reactions),也可以设置分类标签(Labels),更可以给 issue 添加评论(Comment)。

最为重要的是 Github 提供了一套满足了绝大部分需求的 API,囊括了 REST 和 GraphQL 的调用方式,这才是 Github 能够成为我们博客平台的大杀器,这个接下来会详细说明。

不难看出,Github issues 拥有着前文提及的一个博客平台所应具备的各种能力。接下来我们将以 Github issues 作为博客平台的管理后端,以 API 来实现和客户端的数据交互。

天生的前后端分离

关于 Github API 的授权和调试,可以查阅我的另一篇文章《基于 Github API 的图床 Chrome 插件开发全纪录》

我们使用 Github issues 作为博客平台,也就是相当于管理后端。我们在管理后端里面撰写文章,设置标签,回复评论,然后通过 API 调用把数据传送给客户端。

几个比较常用的 v3 API 如下:

当然你也可以使用 v4 的 GraphQL 接口,也是非常的方便,感兴趣的可以自行研究。

管理后端直接用现成的 Github issues 页面,那么客户端则使用 Github 为开发者免费提供的静态页面部署服务 Github pages。要使用这个服务,只需要开通一个仓库,然后在仓库的 Settings 里面找到 Github pages 并打开即可,默认会以 Master 分支的根目录作为静态资源目录,我们只需要把客户端的静态资源直接放置在这里就好。

image

开通了 Github pages 以后,便可以通过其提供的 URL 直接在浏览器里访问到博客了,而博客的数据则完全加载自 Github API。

image

通过已授权的接口,还允许提交评论等功能:

May-22-2019 19-50-23

结语

总结一下,Github issues 提供了一个博客平台所需的的各项基本能力,与 Github 的可靠性, API 的全面性,Github pages 的便捷性结合在一起,都非常适合作为一个博客平台来使用。我基于 Github issues 的个人博客也已经上线,欢迎前来体验:

https://jrainlau.github.io/#/

如果你也觉得不错的话,赶快给自己也搭一个基于 Github issues 的博客吧,期待与你的交流!

查看原文

赞 38 收藏 24 评论 22

jrainlau 发布了文章 · 2019-05-08

基于 Github API 的图床 Chrome 插件开发全纪录

image

最近基于 Github API 开发了一款图床 Chrome 插件 Picee,现在已经开源并上架 Chrome 应用商店。当中的过程涉及到一些有趣的知识点,故将其记录下来。

Github地址:https://github.com/jrainlau/p...

Chrome商店下载地址:Picee

灵感

平时有写点东西的习惯,但是奈何一直找不到合适的图床。有人推荐以微博或者七牛来做图床,但是总给我一种”受制于人“的感觉,不知道什么时候就会被各种限制,比如禁止图片外链等等。后来发现其实 Github 是非常适合做图床的,因为仓库里的文件都可以通过 https://raw.githubusercontent.com 这个链接直接外链以供下载。但是如果为了写个文章而每次添加图片都需要一顿 Git 操作,那么写作体验必定非常不好,如果有更方便的办法就好了——那就是Github API。

Github API

Github 提供了一套完善的 API 以供操作,几乎涵盖了开发一个完整 Github 客户端的所有功能。API 分为 REST 风格的 v3 版本和 GraphQL 风格的 v4 版本。为了使用方便,我选择的是 v3 版本。具体的 API 细节可以在官方文档查看。

要制作一个图床应用,我们只需要用到上传文件的 API 即可。但是在调用这个 API 之前,首选需要用户对应用进行授权,也就是所谓的登录操作。

授权

对于一般的”查看”操作,是不需要授权的,比如获取用户的公开信息,获取公有仓库的 issues 等等。但是有两个场景是需要授权的,其一是任何对于仓库的“增删改查”操作(包括提交 issue,评论等);其二则是对于某 IP 有 API 的调用次数限制,若这个 IP 调用 Github API 的次数过多则需要授权。

那么授权应该怎么做呢?官方提供了三种办法,分别是 BasicoAuth2 tokenoAuth2 key/secret

Basic授权

也就是最传统的账号密码授权方式,我们可以在命令行用 curl 来测试之:

curl -u "账号:密码" https://api.github.com

如果是正确的账号密码,则会返回一系列的内容,否则会返回错误信息。

对于开发来说,我更推荐使用 Postman 来对 API 进行测试:

image

点开右边的 code ,可以看到 JS 代码:
image

其中 xhr.setRequestHeader("Authorization", "Basic xxxxxx");就是我们需要设置的授权 header,当中的 xxxxxx 是这么来的:

btoa(username + ':' + password)

oAuth2 token 授权

对于账号密码来说,轻易地在第三方平台输入其实并不那么安全,那么有没有办法既能保障账户的安全,又能实现授权的需求呢?答案就是 oAuth token。

简单来说,oAuth token 相当于用户提供给第三方的一张授权令牌,第三方通过这张令牌可以获得用户所允许使用的一系列权限,但是却不会知晓用户的账号和密码,于是便得以在有效保障用户账号安全的同时,又能方便地对第三方应用进行授权。

在 Github 里,可以在这个地方设置生成具有某些权限的 token:

image

最后在 Postman 里选择 OAuth 2.0 或者 Bearer Token,然后把这串 token 粘贴进去即可。

image

其中的授权 header 为 Bearer token

oAuth 2 key/secret授权

这种授权方式是通过生成一对 key/secret,来允许第三方获取用户的公开信息,是一种只读的授权方式,无法对仓库进行改写操作,主要用于第三方登录,故在这里不适用。更多关于 key/secret 的内容可以查看阮一峰的《GitHub OAuth 第三方登录示例教程》,写得非常生动详细。


在了解了三种授权方式之后,我们就可以进行下一步操作,实现图片的上传了。

图片上传 API

图片上传使用了 content API 的 create-a-file 接口,通过 PUT 发送一条文件内容为 base64 的请求到指定的仓库目录。

image

这里着重圈出了必须把文件进行 base64 编码,否则接口调用将会出错。

通过 btoa('hello world) 方法把 hello world 转成 base64,然后放在 Postman 里测试一下:

image

image

看来效果是OK的,接下来就是对图床插件进行开发的步骤了。

Chrome 插件开发

除了看官方文档学习插件开发以外,也可以参考由@小茗同学 所写的《【干货】Chrome插件(扩展)开发全攻略》,里面对于 Chrome 插件的开发有着详细的叙述,非常值得一读。

读完上面推荐的文章之后,我选择使用 VueJS 进行开发。由于项目比较简单,所以我没有用任何的打包工具,直接通过 script 的方式引入 VueJS。值得注意的是,Chrome 插件不允许行内 script 和行内 style,所以任何的 css 和 js 文件都必须通过本地文件链接的方式去使用。另外由于我们的 JS 是运行在 Chrome 环境的,所以可以放心大胆地使用 es 模块和 async/await 等高级语法,而无需任何的构建工具参与。

但是在使用 VueJS 的第一步我就遇到了问题,绑定了 new Vue() 的 DOM 元素竟然显示不出来。经过查证,原来 Chrome 插件有 Content Security Policy (CSP) 限制,默认是不支持 eval()new Function() 等方式运行代码的,而完整版的 VueJS 恰好是这么干的(官网有说),所以就出问题了。那么怎么解决呢?很简单,在 manifest.json 里面声明一下就好了:

// manifest.json

{
  // ...
  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"
}

这里我采用的是 popup 形式的插件,弹出来的窗口就是项目所定义的 index.html。如果要调试插件的页面,可以直接在插件弹出的窗口点击右键,然后点击“检查”,就会弹出我们熟悉的开发者工具了。如果插件文件有改动,除了重新打开插件以外,我们也可以在开发者工具通过 cmd + r 去直接刷新,省去了多次点击的麻烦。

功能实现

经过前面的准备,我们已经掌握了如何对 Github API 进行授权然后上传图片的办法,接下来就是在业务逻辑里去实现它们。我封装了一下原生的 fetch 方法,让它更方便调用:

const $fetch = (options) => {
  return window.fetch(options.url, {
    method: options.method || 'GET',
    headers: {
      "Content-Type": "application/json",
      "Authorization": localStorage.getItem('picee_token')
    },
    body: JSON.stringify(options.body) || null,
    mode: 'cors'
  })
    .then(async res => {
      if (res.status >= 200 && res.status < 400) {
        return {
          status: res.status,
          data: await res.json()
        }
      } else {
        return {
          status: res.status,
          data: null
        }
      }
    })
    .catch(e => e)
}

export default $fetch

请求接口时携带的授权 header 所需的 token,我把它们存放在插件的 localStorage 下,方便调用。

有了请求接口的方法以后,接下来就要完成选择图片和把图片转化成 base64 的工作。这里我复用了另一个作品里的 chooseImg.jspaste.js 方法,最终能够支持以选择、粘贴、拖拽的方式上传图片。

剩下的一些功能细节就不赘述了,代码非常简单,建议读者们自行查阅。

应用发布

准备好了 logo,描述等善后工作之后,就可以正式提交应用发布了。我们可以在开发者信息中心里面把插件提交上去,填入必要的信息以后点击发布,等待审核完成。但是在此之前,你必须支付5美元的开发者注册费,国内的开发者在完成这一步的时候可能会遇到蛮大的困难,这一个问题在知乎也有讨论:如何在中国使用信用卡支付“Chrome 网上应用店”开发者注册费?。我是通过万能的淘宝搞定的。

在完成注册之后和发布以后,就能看到插件的主页了:

chrome google com_webstore_detail_picee_nmeeieecbmdnilkkaliknhkkakonobbc(iPad)

值得注意的是,刚发布的插件是暂时不能被搜索出来的,需要等待一段时间以后才能搜索出来。

image

至此,整个插件的开发——发布流程就已经完成了。

查看原文

赞 52 收藏 38 评论 1