«

《WebAssembly标准入门》读书笔记之JavaScript基础

image.png

—— 当歌曲和传说都已经缄默的时候,只有代码还在说话。

[toc]

《WebAssembly标准入门》读书笔记系列


本文是《WebAssembly标准入门》第1章节主要内容,本文将介绍JavaScript中和WebAssembly有关的几个关键技术,包括console模块、函数和闭包、Promise的用法和二进制数组对象TypedArray的简要用法,让你对JavaScript技术有一个初步的认识。

console对象

console对象是重要的JavaScript对象,通过该对象可以实现输出打印功能。而打印输出函数正是学习每种编程语言第一个要掌握的技术。console也是我们的基本调试手段。其中console.log是最常用的打印方法,可以打印任意对象。

下面的代码向控制台输出“你好,WebAssembly!”:

console.log('你好,WebAssembly!')  

console是一个无须导入的内置对象,在Node.js和浏览器环境均可使用。console对象的方法主要分为简单的日志输出、assert断言、输出对象属性、调试输出以及简单的时间测量等。

需要说明的是,在不同的浏览器环境中,console对象可能扩展了很多特有的方法。表1-1给出的是Node.js和主流的浏览器提供的方法,其中loginfowarnerror分别用于输出日志信息、一般信息、警告信息和错误信息。

image.png

下面是console.log的常见用法:

console.log(123)  
console.log(123, 'abc')  
console.log(123, 'abc', [4,5,6])  

console.log等方法还支持类似C语言中printf函数的格式化输出,它可以支持下面这些格式。

%s:输出字符串。 ▪%d:输出数值类型,整数或浮点数。 ▪%i:输出整数。 ▪%f:输出浮点数。 ▪%j:输出JSON格式。 ▪%%:输出百分号('%')。

下面是用格式化的方式输出整型数:

const code = 502;  
console.error('error #%d', code);  

断言对于编写健壮的程序会有很大的帮助。下面的开方函数通过前置断言确保输入的参数是正整数:

function sqrt(x) {  
    console.assert(x >= 0)
    return Math.sqrt(x)
}

前置断言一般用于确保是合法的输入,后置断言则用于保证合法的输入产生了合法的输出。例如,在连接两个字符串之后,我们可以通过后置断言使连接后的字符串长度大于或等于之前的任何一个字符串的长度:

function joinString(a, b) {  
    let s = a + b
    console.assert(s.length >= a.length)
    console.assert(s.length >= b.length)
    return s
}

不应该在断言中放置具有功能性逻辑的代码,下面使用断言的方式在实际应用中应该尽量避免:

let i = 90  
console.assert(i++ < 100)  
console.log('i:', i)  

因为最终输出的结果依赖于断言语句中i++的正常运行,所以如果最终运行的环境不支持断言操作,那么程序将产生不同的结果。

函数和闭包

在高级编程语言中,函数是一个比较核心的概念。简单来说,函数就是一组语句的集合。通过将语句打包为一个函数,就可以重复使用相同的语句集合。同时,为了适应不同的场景,函数还支持输入一些动态的参数。从用户角度来说,函数是一个黑盒子,根据当前的上下文环境状态和输入参数产生输出。

我们简单看看JavaScript中如何用函数包装1到100的加法运算:

function sum100() {  
    var result = 0;
    for(var i = 1; i <= 100; i++) {
        result += i;
    }
    return result;
}

function关键字表示定义一个函数。然后函数体内通过for循环来实现1到100的加法运算。sum100通过函数的方式实现了对1到100加法语句的重复利用,但是sum100的函数无法计算1到200的加法运算。

为了增加sum函数的灵活性,我们可以将要计算等差和的上界通过参数传入:

function sum(n) {  
    var result = 0;
    for(var i = 1; i <= n; i++) {
        result += i;
    }
    return result;
}

现在我们就可以通过sum(100)来计算1到100的加法运算,也可以通过sum(200)来计算1到200的加法运算。

和C/C++等编译语言不同,JavaScript的函数是第一对象,可以作为表达式使用。因此可以换一种方式实现sum函数:

var sum = function(n) {  
    var result = 0;
    for(var i = 1; i <= n; i++) {
        result += i;
    }
    return result;
}

上面的代码中sum更像一个变量,不过这个变量中保存的是一个函数。保存了函数的sum变量可以当作一个函数使用。

我们还可以使用箭头函数来简化函数表达式的书写:

var sum = (n) => {  
    var result = 0;
    for(var i = 1; i <= n; i++) {
        result += i;
    }
    return result;
}

其中箭头=>前面的(n)对应函数参数,箭头后面的{}内容表示函数体。如果函数参数只有一个,那么可以省略小括号。类似地,如果函数体内只有一个语句也可以省略花括号。

因为箭头函数写起来比较简洁,所以经常被用于参数为函数的场景。不过需要注意的是,箭头函数中this是被绑定到创建函数时的this对象,而不是绑定到运行时上下文的this对象。

既然函数是一个表达式,那么必然会遇到在函数内又定义函数的情形:

function make_sum_fn(n) {  
    return () => {
        var result = 0;
        for(var i = 1; i <= n; i++) {
            result += i;
        }
        return result;
    }
}

make_sum_fn函数中,通过函数表达式创建了一个函数对象,最后返回了函数对象。另一个重要的变化是,内部函数没有通过参数来引用外部的n变量,而是直接跨越了内部函数引用了外部的n变量。

如果一个函数变量直接引用了函数外部的变量,那么外部的变量将被该函数捕获,而当前的函数也就成了闭包函数。在早期的JavaScript语言中,变量并没有块级作用域,因此经常通过闭包函数来控制变量的作用域。

通过返回的闭包函数,我们就可以为1到100和1到200分别构造求和函数对象:

var sum100 = make_sum_fn(100);  
var sum200 = make_sum_fn(200);

sum100();  
sum200();  

每次make_sum_fn函数调用返回的闭包函数都是不同的,因为闭包函数每次捕获的n变量都是不同的。

Promise对象

JavaScript是一个单线程的编程语言,通过异步、回调函数来处理各种事件。因此,如果要处理多个有先后顺序的事件,那么将会出现多次嵌套回调函数的情况,这也被很多开发人员称为回调地狱。

Promise对象则是通过将代表异步操作最终完成或者失败的操作封装到了对象中。Promise本质上是一个绑定了回调的对象,不过这样可以适当缓解多层回调函数的问题。

通过构造函数可以生成Promise实例。下面代码创造了一个Promise实例:

function fetchImage(path) {  
    return new Promise((resolve, reject) => {
        const m = new Image()
        m.onload = () => { resolve(image) }
        m.onerror = () => { reject(new Error(path)) }
        m.src = path
    })
}

fetchImage返回的是一个Promise对象。Promise构造函数的参数是一个函数,函数有resolvereject两个参数,分别表示操作执行的结果是成功还是失败。内部加载一个图像,当成功时调用resolve函数,失败时调用reject函数报告错误。

返回Promise对象的then方法可以分别指定resolvedrejected回调函数。下面的makeFetchImage是基于fetchImage包装的函数:

const makeFetchImage = () => {  
fetchImage("/static/logo.jpg").then(() => {  
        console.log('done')
    })
}

makeFetchImage()  

makeFetchImage包装函数中,Promise对象的then方法只提供了resolved回调函数。因此当成功获取图像后将输出done字符串。

Promise对象虽然从一定程度上缓解了回调函数地狱的问题,但是Promise的构造函数、返回对象的then方法等地方依然要处理回调函数。因此,ES2017标准又引入了asyncawait关键字来简化Promise对象的处理。

await关键字只能在async定义的函数内使用。async函数会隐式地返回一个Promise对象,Promise对象的resolve值就是函数return的值。下面是用asyncawait关键字重新包装的makeFetchImage函数:

const makeFetchImage = async () => {  
    await fetchImage("/static/logo.jpg")
    console.log('done')
}

makeFetchImage()  

在新的代码中,await关键字将异步等待fetchImage函数的完成。如果图像下载成功,那么后面的打印语句将继续执行。

基于asyncawait关键字,可以以顺序的思维方式来编写有顺序依赖关系的异步程序:

async function delay(ms) {  
    return new Promise((resole) => {
setTimeout(resole, ms)  
    })
}

async function main(...args) {  
    for(const arg of args) {
        console.log(arg)
        await delay(300)
    }
}

main('A', 'B', 'C')  

上述程序中,main函数依次输出参数中的每个字符串,在输出字符串之后休眠一定时间再输出下一个字符串。而用于休眠的delay函数返回的是Promise对象,main函数通过await关键字来异步等待delay函数的完成。

二进制数组

二进制数组(ArrayBuffer对象、TypedArray视图)是JavaScript操作二进制数据的接口。这些对象很早就存在,但是一直不是JavaScript语言标准的一部分。在ES2015中,二进制数组已经被纳入语言规范。基于二进制数组,JavaScript也可以直接操作计算机内存,因为该抽象模型和实际的计算机硬件结构非常地相似,理论上可以优化到近似本地程序的性能。

二进制数组由3类对象组成。

例如,下面的代码先创建一个1024字节的ArrayBuffer,然后再分别以uint8uint32类型处理数组的元素:

let buffer = new ArrayBuffer(1024)

// 转为uint8处理
let u8Array = new Uint8Array(buffer, 0, 100)  
for(int i = 0 ; i < u8Array.length; i++) {  
    u8Array[i] = 255
}

// 转为uint32处理
let u32Array = new Uint32Array(buffer, 100)  
for(int i = 0 ; i < u32Array.length; i++) {  
    u32Array[i] = 0xffffffff
}

TypedArray对象的buffer属性返回底层的ArrayBuffer对象,为只读属性。上述代码中,因为u8Arrayu32Array都是从同一个ArrayBuffer对象构造,所以下面的断言是成立的:

console.assert(u8Array.buffer == buffer)  
console.assert(u32Array.buffer == buffer)  

TypedArray对象的byteOffset属性返回当前二进制数组对象从底层的ArrayBuffer对象的第几个字节开始,byteLength返回当前二进制数组对象的内存的字节大小,它们都是只读属性。

console.assert(u8Array.byteOffset == 0)  
console.assert(u8Array.byteLength == 100)

console.assert(u32Array.byteOffset == 100)  
console.assert(u32Array.byteLength == buffer.byteLength-100)  

TypedArray视图对应的二进制数组的每个元素的类型和大小都是一样的。但是视图可能无法直接对应复杂的结构类型,因为结构体中每个成员的内存大小可能是不同的。

我们可以为结构体的每个成员创建一个独立的TypedArray视图实现操作结构体成员的目的,不过这种方式不便于处理比较复杂的结构体。除了通过复合视图来操作结构体类的数据,还可以通过DataView视图实现同样的功能。

二进制数组用于处理图像或矩阵数据时有着较高的性能。在浏览器中,每个canvas对象底层也是对应的二进制数组。通过canvas底层的二进制数组,我们可以方便地将一个彩色图像变换为灰度图像:

<!DOCTYPE html>

<title>rgba => gray</title>

<body onload="body_onload()">  
<canvas id="myCanvas" width="400" height="300">show image</canvas>

<script>  
function body_onload() {  
    var canvas = document.getElementById('myCanvas')
    var ctx = canvas.getContext('2d')
    var imgd = ctx.getImageData(0, 0, canvas.width, canvas.height)

    var m = new Image()
    m.src = './lena.jpg'
    m.onload = function() {
        ctx.drawImage(m, 0, 0, canvas.width, canvas.height)
        rgba2gray(imgd.data, canvas.width, canvas.height)
    }
}

function rgba2gray(pix, width, height) {  
    console.assert(pix instanceofUint8Array || pix instanceofUint8ClampedArray)
    console.assert(width > 0)
    console.assert(height > 0)
    console.assert(pix.length >= width*height*4)

    for(var y = 0; y < height; y++) {
        for(var x = 0; x < width; x++) {
            var off = (y*width+x)*4

            var R = pix[off+0]
            var G = pix[off+1]
            var B = pix[off+2]
            var gray = (R+G+B)/3

            pix[off+0] = gray
            pix[off+1] = gray
            pix[off+2] = gray
        }
    }
}
</script>  
</body>  

二进制数组是JavaScript在处理运算密集型应用时经常用到的特性。同时二进制数组也是网络数据交换和跨语言数据交换最有效的手段。WebAssembly模块中的内存也是一种二进制数组。

分享