前端之路 - 惜日

珍惜当下 直面未来


  • 首页

  • 标签

  • 分类

  • 归档

WebAssembly 与 AssemblyScript 快速入门

发表于 2021-09-27 | 更新于 2021-10-12 | 分类于 前端开发

WebAssembly 概述

简单来说,WebAssembly 是一种除了 HTML、JS 和 CSS 之外,可在浏览器环境下运行的一种二进制程序文件,文件格式为 wasm。
wasm 文件是一种低级的类汇编语言,运行效率接近原生性能,并可以和 JavaScript 进行交互。

AssemblyScript 概述

虽然 wasm 文件有各种优点,但是并不能使用 JavaScript 编写。通常情况下是使用诸如 C++或 Rust 等语言(语言支持情况)编写并生成 wasm 文件。
这对于前端开发人员来说还需要学习另外一门语言,存在上手难度。好在有 AssemblyScript。
AssemblyScript 是 TypeScript 的子集,包含了最基本的 JS 标准库。可使用有限的 TS 语法编写并生成 wasm 文件,对前端开发人员非常友好。

WebAssembly 关键概念

先看一个例子:

AssemblyScript

1
2
3
4
5
6
declare function log(arg: i32): void;

export function add(a: i32, b: i32): i32 {
log(a + b + 1000);
return a + b;
}

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(async () => {
const buffer = await fetch('../build/optimized.wasm').then(r => r.arrayBuffer());
const memory = new WebAssembly.Memory({ initial: 1 }); // 1
new Uint32Array(memory.buffer)[0] = 123;
const importObj = {
wasm: {
log: args => console.log('log from wasm', args),
},
env: {
memory,
},
};
const { instance, module } = await WebAssembly.instantiate(buffer, importObj); // 2
const exportObj = instance.exports;
const result1 = exportObj.add(1, 2);
console.log('result1', result1);

const table = new WebAssembly.Table({ initial: 1, element: 'anyfunc' }); // 3
table.set(0, exportObj.add);
const result2 = table.get(0)(2, 3);
console.log('result2', result2);
})();

运行结果

1
2
3
4
log from wasm 1003
result1 3
log from wasm 1005
result2 5

模块

模块表示一个已经被浏览器编译过的 WebAssembly 二进制代码,可以被浏览器缓存和通过 postMessage 在 worker 中共享。一般一个 wasm 文件会被编译成一个模块。

注释 2 中的module就是一个 wasm 模块,表示 wasm 编译后的结果,可以被缓存。

实例

调用WebAssembly.Instance传入 wasm 文件,会创建一个 WebAssembly 实例,类似 JS 中的 new 操作。一个实例包含了模块运行时的所有状态,包括导入导出值、内存和表格等。

注释 2 中的instance就是一个 wasm 实例,其中主要包含了模块导出的对象,可以直接被 JS 调用。

内存

内存是将 WebAssembly 运行的内存表示为 JS 中的 ArrayBuffer,可通过 TypedArray 对其进行读写。支持导入到 wasm 和从 wasm 导出。

注释 1 在 JS 创建了一段内存,将第一个元素设置为 123,并将其导入到 wasm 中使用。

表格

表格实际是引用值地址组成的数组,每一项表示在 wasm 中的引用类型,目前 WebAssembly 只支持函数类型。

注释 3 创建了一个表格,并将第 0 项设置为 wasm 中 add 函数的引用,可以在其后直接调用。

AssemblyScript 开发环境的搭建

  1. 新建一个项目,并用yarn init初始化
  2. 安装必要的依赖

    1
    2
    yarn add assemblyscript --dev
    yarn add @assemblyscript/loader
  3. 初始化项目目录结构和配置

    1
    yarn asinit .

    确认初始化,并编译 wasm 后,目录结构如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    PROJECT_ROOT
    ├── asconfig.json
    ├── assembly
    | ├── index.ts
    | └── tsconfig.json
    ├── build
    | ├── optimized.wasm
    | ├── optimized.wasm.map
    | ├── optimized.wat
    | ├── untouched.wasm
    | ├── untouched.wasm.map
    | └── untouched.wat
    ├── index.js
    ├── package.json
    ├── tests
    | └── index.js
    └── yarn.lock
  4. 编辑assembly/index.ts中的 ts 文件 (AssemblyScript)

  5. 运行 yarn asbuild会在 build 目录中生成 wasm 文件
  6. 可以通过修改并运行node index.js测试生成的 wasm,也可以在其他 node 项目中引用此项目直接使用 wasm

    1
    2
    const myModule = require('path/to/mymodule');
    myModule.add(1, 2);

AssemblyScript HelloWorld

使用 AssemblyScript 实现一个计算加法的程序,并在 wasm 内输出 log 到控制台
首先编写 wasm.ts 文件

注:AssemblyScript 是 TypeScript 的子集,并有一些书写上的限制,如没有 number 类型,需要用更具体的类型代替,如 i32, u32, f32, f64 等。

使用declare声明从 JS 中导入的函数
使用export语法导出函数到 JS

1
2
3
4
5
6
declare function log(arg: string): void;

export function add(a: i32, b: i32): i32 {
log(`run add: ${a}, ${b}`);
return a + b;
}

编写 index.js 文件,并在 html 文件中使用 ESModule 方式引用
使用 @assemblyscript/loader 而不使用原生的方法是因为需要用到 loader 提供的 runtime(下文会作具体说明)
注:在编译 wasm 时需要提供 --exportRuntime参数

1
<script type="module" src="./index.js"></script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import loader from 'https://cdn.jsdelivr.net/npm/@assemblyscript/loader/index.js';

const main = async () => {
const importObj = {
wasm: {
log,
},
};
const buffer = await fetch('../build/optimized.wasm').then(r => r.arrayBuffer());
const {
exports: { __getString, add },
instance,
} = await loader.instantiate(buffer, importObj);
function log(args) {
console.log('log from wasm:', __getString(args));
}
const result = add(1, 2);
console.log('result', result);
};

main();

输出结果:

1
2
log from wasm: run add: 1, 2
result 3

在 AssemblyScript 中使用的 JS 函数可以通过loader.instantiate的第二个参数,以文件名为 key 的对象中传入。

在 AssemblyScript 导出的 add 函数可以在loader.instantiate返回的exports中使用,除了自己定义的 add 函数外,loader 还导出了一些工具函数用来和 JS 进行数据转换。例子中用到了__getString方法。

AssemblyScript 的导入和导出

导入

可以在任意文件声明导入的接口类型,然后从 JS 接收具体的函数。
例:新建一个模块,声明一个 log 函数接口,函数具体实现稍后会从 JS 中导入。

imports.ts

1
export declare function log(arg: string): void;

其他文件就可以通过如下方式引入导入的函数

wasm.ts

1
import { log } from './imports';

log 函数需要在 JS 中通过导入对象的形式传入(key 为包含 declare 方法的文件名)

index.js

1
2
3
4
5
6
7
8
9
10
const importObj = {
// imports: 包含函数声明的文件名
imports: {
// log: 声明的函数名称
log: arg => {
console.log(arg);
},
},
};
const { instance } = await loader.instantiate(fetch('../build/optimized.wasm'), importObj);

导出

使用标准的 ESModule export 语法在入口文件处导出函数或常量

例:

wasm.ts

1
2
3
4
5
export function add(a: i32, b: i32): i32 {
log(`run add: ${a}, ${b}`);
return a + b;
}
export const num: i32 = 256;

AssemblyScript 与 JS 的数据转换

AssemblyScript 使用线性内存管理数据,底层只支持 number 类型 (i32, u8, f64 等),所以字符串、数组等类型不能直接导入导出或通过函数参数传入和返回。如需使用需要用到 AssemblyScript 的 loader (@assemblyscript/loader)。
通过loader.instantiate的方式载入用 AssemblyScript 编译后的 wasm 后,会同时导出一些内置的工具函数,用来实现字符串或数组等类型的转换。
附带的工具函数 API

常用方法如下:

  • function __newString(str: string): number: 创建字符串到指针值,用于向 wasm 传递字符串。
  • function __getString(ptr: number): string: 从 wasm 指针,读取字符串。
  • function __newArray( id: number, values: valuesOrCapacity?: number[] | ArrayBufferView | number ): number: 创建数组并传递到 wasm
  • function __getArray(ptr: number): number[]: 从 wasm 读取数组

字符串类型

AssemblyScript

1
2
3
export function concat(a: string, b: string): string {
return a + b;
}

JavaScript

1
2
3
4
5
6
7
8
9
10
const { concat } = myModule.exports;
const { __newString, __getString } = myModule.exports;
function doConcat(aStr, bStr) {
const aPtr = __newString(aStr);
const bPtr = __newString(bStr);
const cPtr = concat(aPtr, bPtr);
const cStr = __getString(cPtr);
return cStr;
}
console.log(doConcat('Hello ', 'world!'));

数组类型

AssemblyScript

1
2
3
4
5
6
7
8
export function sum(arr: Int32Array): i32 {
let sum = 0;
for (let i = 0, k = arr.length; i < k; ++i) {
sum += unchecked(arr[i]);
}
return sum;
}
export const Int32Array_ID = idof<Int32Array>();

JavaScript

1
2
3
4
5
6
7
8
9
const { sum, Int32Array_ID } = myModule.exports;
const { __newArray } = myModule.exports;

function doSum(values) {
const arrPtr = __newArray(Int32Array_ID, values);
return sum(arrPtr);
}

console.log(doSum([1, 2, 3]));

使用 unchecked 对提供的表达式不进行边界检查。

创建数组前需要在 AssemblyScript 中定义相关类型的 ID,并使用 ID 在 JS 中创建数组。
此外 AssemblyScript 还提供以下函数,获取类型化数组。

1
2
3
4
5
6
7
8
9
10
11
function __getInt8ArrayView(ptr: number): Int8Array;
function __getUint8ArrayView(ptr: number): Uint8Array;
function __getUint8ClampedArrayView(ptr: number): Uint8ClampedArray;
function __getInt16ArrayView(ptr: number): Int16Array;
function __getUint16ArrayView(ptr: number): Uint16Array;
function __getInt32ArrayView(ptr: number): Int32Array;
function __getUint32ArrayView(ptr: number): Uint32Array;
function __getInt64ArrayView(ptr: number): BigInt64Array;
function __getUint64ArrayView(ptr: number): BigUint64Array;
function __getFloat32ArrayView(ptr: number): Float32Array;
function __getFloat64ArrayView(ptr: number): Float64Array;

自定义类

AssemblyScript 可以导出自定义类,类中可以包含数字和字符串类型的属性和自定义方法。

AssemblyScript

1
2
3
4
5
6
7
8
9
10
export class Foo {
constructor(public str: string) {}
getString(): string {
return this.str;
}
}

export function getFoo(): Foo {
return new Foo('Hello world!');
}

JavaScript

1
2
3
4
5
6
7
8
const { Foo, getFoo } = myModule.exports;
const { __getString, __pin, __unpin } = myModule.exports;

const fooPtr = __pin(getFoo()); // pin if necessary
const foo = Foo.wrap(fooPtr);
const strPtr = foo.getString();
console.log(__getString(strPtr));
__unpin(fooPtr); // unpin if necessary

使用 __pin 与 __unpin 防止 AssemblyScript 中的对象被回收。

可使用 as-bind 库简化数据转换操作:NPM

AssemblyScript 内存相关操作

AssemblyScript 支持和 JS 共享一段线性内存用来保存和处理数据。
注:在编译 wasm 时需要提供 --importMemory参数

例:用操作内存的方法来实现一个加法

  • load 用于读取指定字节位置的数据
  • store 用于将数据存入指定字节的位置

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(async () => {
const buffer = await fetch('../build/optimized.wasm').then(r => r.arrayBuffer());
const memory = new WebAssembly.Memory({ initial: 1 });
const wasmMem = new Int32Array(memory.buffer);
wasmMem[0] = 2; // 第一个加数
wasmMem[1] = 3; // 第二个加数
const importObj = {
env: {
memory,
},
};
const {
instance: { exports },
module,
} = await WebAssembly.instantiate(buffer, importObj);
// exports.memory === memory // true
exports.add();
console.log('memory', wasmMem);
const result = wasmMem[2]; // 加法结果
console.log('result', result);
})();

AssemblyScript

1
2
3
4
5
6
7
8
9
// memory.grow(1); // 单位为页,1页为64KB

// i32对应的长度为4B
const UNIT_SIZE: i32 = 4;
export function add(): void {
// 将内存中第一项和第二项相加,结果保存到第三项
const result = load<i32>(0 * UNIT_SIZE) + load<i32>(1 * UNIT_SIZE);
store<i32>(2 * UNIT_SIZE, result);
}

AssemblyScript 相比于 TypeScript 存在的限制

  • 需要指明更具体的 number 类型:
AssemblyScript Type WebAssembly Type Description
i32 i32 A 32-bit signed integer
u32 i32 A 32-bit unsigned integer
i64 i64 A 64-bit signed integer
u64 i64 A 64-bit unsigned integer
f32 f32 A 32-bit float
f64 f64 A 64-bit float
i8 i32 An 8-bit signed integer
u8 i32 An 8-bit unsigned integer
i16 i32 A 16-bit signed integer
u16 i32 A 16-bit unsigned integer
bool i32 A 1-bit unsigned integer
  • 由于是严格类型,所以 === 和 == 是等价的

  • 所有类型必须是确定的,没有 any、undefined、联合类型

  • 对象不能在运行时检查字段可用性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function doSomething(foo: Foo): void {
    if (foo.something) {
    // ... some code ...
    foo.something.length; // fails
    }
    }

    function doSomething(foo: Foo): void {
    let something = foo.something;
    if (something) {
    something.length; // works
    }
    }
  • 不支持 JSON、RegExp (存在社区解决方案)

  • Date支持有限 (仅包含 UTC 相关的实现)
  • 不支持函数闭包
  • 不支持迭代器和扩展运算符
  • 不支持try...catch和throw异常处理
  • 不支持 Promise
  • JS 动态特性的相关功能

具体说明及其他限制详见官方文档

其他特性

  • 支持一些装饰器函数,用来更改函数的行为或优化性能 官方文档

总结

由于 WebAssembly 不支持 JS 中的文档对象、网络、Promise 等,且数据类型支持有限,所以目前 WebAssembly 更适合对算法相关逻辑进行封装,通过缓存相关技术,可以将一个大的算法拆成几个小的 wasm,再通过 JS 粘到一起,兼顾高性能与灵活性。

Storybook 入门指南

发表于 2020-09-27 | 更新于 2021-09-27 | 分类于 前端开发

Storybook 是什么

使用 Storybook 可以为你的项目一键生成样式指南(Style Guide)页面。
使用 组件驱动开发(CDD) 的模式,首先完成基础组件,通过组件复用的方式构建功能模块和页面。
使用 CDD 开发有如下好处:

  1. 开发和设计更方便交流,更快速的迭代产品原型。
  2. UI 与业务分离,基础组件间无依赖,可以并行开发。
  3. 组件拆分更合理、规范,整体风格由基础组件决定,可以保证 UI 风格的统一性。
  4. 组件包含文档和样例,方便其他开发人员使用。

Storybook 是一种 CDD 的实现方案,支持多种 UI 框架,一键生成在线样式指南。包含组件文档、组件预览、在线调试等功能。

Storybook 优点

  1. 环境配置简单快捷,一条命令即可完成配置 (CRA 基础上)。
  2. 样式指南编写简单,可自动提取 Props 定义、组件名称等,无需繁琐步骤即可使用。
  3. 提供插件机制,功能可扩展,默认支持响应式设计,预览背景,实时属性更改,自动化文档等。
  4. 支持 MDX 格式,可编写带组件预览的 Markdown 文档,亦可作为纯文档使用。
  5. 可以独立打包成 App,作为 UI 文档发布。

安装

在项目中运行

1
npx sb init

会在项目中生成如下文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
.storybook
├── main.js // stories 路径和插件配置
└── preview.js // 全局组件预览配置

src
├── index.tsx
└── stories // 默认生成的 Demo
├── Button.stories.tsx
├── Button.tsx
├── Header.stories.tsx
├── Header.tsx
├── Introduction.stories.mdx
├── Page.stories.tsx
├── Page.tsx
├── assets
│ ├── code-brackets.svg
│ ├── colors.svg
│ ├── comments.svg
│ ├── direction.svg
│ ├── flow.svg
│ ├── plugin.svg
│ ├── repo.svg
│ └── stackalt.svg
├── button.css
├── header.css
└── page.css

除此之外,还会自动修改package.json,添加相关依赖和 scripts

1
2
3
4
{
"storybook": "start-storybook -p 6006 -s public",
"build-storybook": "build-storybook -s public"
}

运行yarn storybook即可直接预览 Demo

编写 Stories

概述

Story 文件以.stories.tsx结尾,如存在自定义文档,则以.stories.mdx结尾。
每个 story 文件为一个菜单项,通过 ESModule 的形式导出。

default 导出为页面配置 (组件、标题等)
export 导出为组件的每种 Props 枚举的样式,可添加多种展示方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';

import { Button, ButtonProps } from './Button';

export default {
title: 'Example/Button',
component: Button,
} as Meta;

const Template: Story<ButtonProps> = (args) => <Button {...args} />;

export const Primary = Template.bind({});
Primary.args = {
primary: true,
label: 'Button',
};

export const Secondary = Template.bind({});
Secondary.args = {
label: 'Button',
};

组件预览页面

首先配置组件默认导出,必填项为title

1
2
3
4
export default {
title: 'Example/Button',
component: Button,
} as Meta;
  • title: 用于组织菜单层次结构,可以通过/创建子目录(如:Example/Component/Button)
  • component: Story 相关的组件(用于自动生成文档等)

导出每个组件有两种方式

  • 可直接导出组件

    1
    export const Primary = () => <Button primary label="Button" />;

    直接导出组件不包含控制选项,仅提供样式预览

  • 使用 Args 方式导出(推荐)

    1
    2
    3
    4
    5
    6
    7
    const Template: Story<ButtonProps> = (args) => <Button {...args} />;

    export const Primary = Template.bind({});
    Primary.args = {
    primary: true,
    label: 'Button',
    };

    使用 Args 导出模式可自动添加 Props 相关控制选项,并且重用 Props 很方便

    1
    2
    3
    4
    5
    const Secondary = ButtonStory.bind({});
    Secondary.args = {
    ...Primary.args,
    primary: false,
    };

自定义组件文档

Storybook 支持自行编写组件相关文档,文件后缀名为.stories.mdx
MDX = Markdown + JSX

首先需要先定义菜单名称和相关组件(类似.stories.tsx的默认导出)

1
2
3
4
import { Meta, Canvas, Story } from '@storybook/addon-docs/blocks';
import { Button } from './Button';

<Meta title="Documents/Button" component={Button} />;

接下来可以使用 Markdown 语法和 Canvas、Story 等 JSX 标记编写文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export const Template = (args) => <Button {...args} />;

# 按钮组件

- 这是一个按钮
- 好看的按钮

## Primary 按钮

<Story name="Primary" args={{ primary, label: 'Button' }}>
{Template.bind({})}
</Story>

## 大与小

<Canvas>
<Story name="Large" args={{ size: 'large', label: 'Button' }}>
{Template.bind({})}
</Story>
<Story name="Small" args={{ size: 'small', label: 'Button' }}>
{Template.bind({})}
</Story>
</Canvas>
  • Story: 组件展示
  • Canvas: 包含代码片段的展示块

此段代码生成如下效果

custom-doc

注: 每个 Story 块生成一个子项,多个子项共用一个文档

组件注释方法

组件 Props 注释(用于生成文档的 Props 描述文字)
组件行首注释(用于生成文档的组件描述文字)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import React from 'react';

export interface ButtonProps {
/** 是否为主按钮 */
primary?: boolean;
/** 按钮背景颜色 */
backgroundColor?: string;
/** 按钮大小 */
size?: 'small' | 'medium' | 'large';
/** 按钮文字 */
label: string;
/** 点击回调函数 */
onClick?: () => void;
}

/** 这是一个按钮 */
export const Button: React.FC<ButtonProps> = ({
primary = false,
size = 'medium',
backgroundColor,
label,
...props
}) => {
return (
<button
type="button"
className={[
'storybook-button',
`storybook-button--${size}`,
primary ? 'storybook-button--primary' : 'storybook-button--secondary',
].join(' ')}
style={{ backgroundColor }}
{...props}
>
{label}
</button>
);
};

此段代码生成如下效果(注意红框内的文字)
jsx-doc

常用配置

Storybook 配置可分别指定作用范围,分为组件配置、Story 文件配置、全局配置,继承关系如下:

  • 全局配置

    .storybook/preview.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    export const parameters = {
    backgrounds: {
    default: 'twitter',
    values: [
    {
    name: 'twitter',
    value: '#00aced',
    },
    {
    name: 'facebook',
    value: '#3b5998',
    },
    ],
    },
    };
  • Story 文件配置

    Button.stories.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    export default {
    title: 'Button',
    parameters: {
    backgrounds: {
    default: 'twitter',
    values: [
    { name: 'twitter', value: '#00aced' },
    { name: 'facebook', value: '#3b5998' },
    ],
    },
    },
    };
  • 组件配置

    Button.stories.js

    1
    2
    3
    4
    export const Large = Template.bind({});
    Large.parameters = {
    backgrounds: { default: 'facebook' },
    };

装饰器

使用装饰器可以在组件渲染时,包裹固定的上下文或样式。

  • 全局配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import { ThemeProvider } from 'styled-components';

    export const decorators = [
    (Story) => (
    <ThemeProvider theme="default">
    <Story />
    </ThemeProvider>
    ),
    ];
  • Story 文件配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    export default {
    title: 'Button',
    component: Button,
    decorators: [
    (Story) => (
    <div style={{ padding: '3em' }}>
    <Story />
    </div>
    ),
    ],
    };
  • 组件配置

    1
    2
    export const Primary = …
    Primary.decorators = [(Story) => <div style={{ padding: '3em' }}><Story/></div>]

自定义 Controls 类型

在 Controls 面板中,Storybook 会自动通过 Props 的类型选择合适的控件,也可以手动指定需要的控件。

.stories.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export default {
title: 'Button',
component: Button,
argTypes: {
backgroundColor: { control: 'color' },
loadingState: {
control: {
type: 'inline-radio',
options: ['loading', 'error', 'ready'],
},
},
width: {
control: {
type: 'range',
min: 400,
max: 1200,
step: 50,
},
},
},
};

argTypes属性可以指定某个 Props 使用指定的控件。可用的 Control 属性如下:

Data Type Control Type Description Options
array array serialize array into a comma-separated string inside a textbox separator
boolean boolean checkbox input -
number number a numeric text box input min, max, step
range a range slider input min, max, step
object object json editor text input -
enum radio radio buttons input options
inline-radio inline radio buttons input options
check multi-select checkbox input options
inline-check multi-select inline checkbox input options
select select dropdown input options
multi-select multi-select dropdown input options
string text simple text input -
color color picker input that assumes strings are color values -
date date picker input -

自定义 Props 描述

Story 可以覆盖默认的 Props 描述(来自静态代码分析)
还可以添加大段文字,以说明 Props 的用途

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export default {
title: 'Example/Button',
component: Button,
argTypes: {
label: {
name: '标签',
type: {
required: false,
},
description: 'overwritten description',
table: {
type: {
summary: 'something short',
detail: 'something really really long',
},
defaultValue: {
summary: 'default summary',
detail: 'default detail',
},
},
control: {
type: null,
},
},
},
} as Meta;

插件系统

官方插件列表:https://github.com/storybookjs/storybook/tree/master/addons

添加插件的一般步骤

  1. 使用 yarn 或 npm 安装相关依赖
  2. 修改.storybook/main.js中的addons字段,添加插件
  3. 在.storybook/preview.js中添加相关配置(可选)

快照测试

  1. 安装插件:yarn add --dev @storybook/addon-storyshots react-test-renderer
  2. 在src/stories中新建文件snapshoot.test.ts

    1
    2
    3
    import initStoryshots from '@storybook/addon-storyshots';

    initStoryshots();
  3. 运行yarn test即可在src/stories中生成__snapshots__文件夹,包含 stories 中组件的快照

制作树莓派 (Raspberry Pi) 系统备份

发表于 2020-04-18 | 更新于 2021-09-27 | 分类于 Linux
  1. 查看树莓派空间使用情况及分区信息来建立 IMG 文件
1
2
df -h
sudo fdisk -l
  1. 安装必要软件
1
sudo apt-get install dosfstools dump parted kpartx
  1. 建立空白 IMG 文件并分区
1
2
3
4
dd if=/dev/zero of=raspberrypi.img bs=1MB count=3072
parted -s raspberrypi.img -- mklabel msdos
parted -s raspberrypi.img -- mkpart primary fat32 8192s 212991s
parted -s raspberrypi.img -- mkpart primary ext4 212992s -1
  1. 挂载镜像到 loop 设备
1
sudo losetup --show -f raspberrypi.img

命令会返回链接后的设备路径,本例中为/dev/loop0

  1. 映射分区
1
sudo kpartx -va /dev/loop0

kpartx 运行成功将打印映射后的信息,在 /dev/mapper 目录下可以找到两个分区

  1. 格式化分区
1
2
sudo mkfs.vfat /dev/mapper/loop0p1
sudo mkfs.ext4 /dev/mapper/loop0p2
  1. 插入待备份的 SD 卡

重要:卸载桌面的自动挂载盘符,否则 dump 数据时会出错

手动挂载磁盘

1
2
3
4
sudo mkdir /mnt/usb1
sudo mkdir /mnt/usb2
sudo mount -t vfat /dev/sdc1 /mnt/usb1
sudo mount -t ext4 /dev/sdc2 /mnt/usb2
  1. 备份数据

创建一个用来挂载 img 镜像中分区的目录

1
sudo mkdir /mnt/image

挂载 img 中的 fat 分区

1
sudo mount -t vfat /dev/mapper/loop0p1 /mnt/image

用 cp 命令拷贝 SD 卡的 fat 分区数据到镜像的 fat 分区:

1
sudo cp -rf /mnt/usb1/* /mnt/image

拷贝完成,卸载 img 的 fat 分区

1
sudo umount /mnt/image

挂载 img 的 ext4 分区

1
sudo mount -t ext4 /dev/mapper/loop0p2 /mnt/image

切换到 /mnt/image 目录,ext4 文件系统需要使用 dump 和 restore 来完整拷贝数据

1
2
3
4
cd /mnt/image
sudo rm -rf lost+found
sudo dump -0uaf - /mnt/usb2 | sudo restore -rf -
sudo rm restoresymtable

dump 的参数

  • 0: 完全备份;
  • u: 当成功备份后信息写入/var/lib/dumpdates
  • a: 自动大小
  • f: 指定备份设备,后跟 - 表明写到标准输入
  1. 修改 PARTUUID

查看 mapper 设备的 PARTUUID (/dev/mapper/loop0p1 & /dev/mapper/loop0p2)

1
sudo blkid

修改/mnt/image/etc/fstab 中对应分区的 PARTUUID

再次挂载 boot 分区到/mnt/image

1
2
sudo umount /mnt/image
sudo mount -t vfat /dev/mapper/loop0p1 /mnt/image

修改/mnt/image/cmdline.txt 中 root 的 PARTUUID

  1. 清理

镜像备份制作完成,需要卸载镜像

1
2
3
4
5
cd
sudo umount /mnt/image
sudo umount /mnt/usb1
sudo umount /mnt/usb2
sudo kpartx -d /dev/loop0

现在 raspberrypi.img 中已包含了完整的系统备份,可使用 Win32 Disk Imager 等工具烧录到 TF 卡中。首次成功启动后,执行 sudo raspi-config,选择 Advanced Options -> Expand Filesystem 扩展 TF 卡可用空间。

如何优雅的在 Puppeteer 环境中运行 JavaScript

发表于 2019-09-21 | 更新于 2021-09-27 | 分类于 前端开发

有时我们可能需要在 Puppeteer 环境中执行一段 JS 代码。
根据官方提供的 API,我们有两种选择,一种是添加 script 标签的方式引入 JS。

page.addScriptTag(options)

  • options <[Object]>
  • url <[string]> URL of a script to be added.
  • path <[string]> Path to the JavaScript file to be injected into frame. If path is a relative path, then it is resolved relative to [current working directory].
  • content <[string]> Raw JavaScript content to be injected into frame.
  • type <[string]> Script type. Use ‘module’ in order to load a Javascript ES6 module. See script for more details.
  • returns: <[Promise]<[ElementHandle]>> which resolves to the added tag when the script’s onload fires or when the script content was injected into frame.

另一种是使用page.evaluate

1
2
3
4
const result = await page.evaluate(x => {
return Promise.resolve(8 * x);
}, 7);
console.log(result); // prints "56"
  • page.addScriptTag虽然可以引用本地文件作为 JS 执行,但是模块系统(ES6 Module 和 CommonJS 等)支持并不完善,部分 ES6 代码不支持 (最新 Chrome 可忽略)。
  • page.evaluate中的代码相当于在 DevTools 的控制台执行的,同样也有模块系统和 ES6 的问题,只是函数传值比page.addScriptTag方便。

所以我认为最好的解决方式是引入 webpack 和 babel 的编译机制。
具体方案是使用 Webpack Node API 配合 memory-fs 将要执行的 JS 文件作为入口,将编译结果输出为字符串,再通过page.evaluate执行。


首先我们需要使用 webpack、memory-fs 和 babel 模块,所以先安装相关依赖。
yarn add webpack memory-fs @babel/core @babel/preset-env babel-loader

接下来我们编写一个buildModule函数来将指定文件作为入口,将相关模块依赖打包为 JS Bundle 字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const webpack = require('webpack');
const MemoryFS = require('memory-fs');

const buildModule = file => {
const compiler = webpack({
mode: 'development',
devtool: 'cheap-module-eval-source-map',
entry: require.resolve(file),
output: {
filename: 'bundle.js',
path: '/build',
},
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
},
},
],
},
});
const fs = new MemoryFS();
compiler.outputFileSystem = fs;
return new Promise((resolve, reject) => {
compiler.run(error => {
if (error) {
reject(error);
return;
}
const content = fs.readFileSync('/build/bundle.js');
resolve(content.toString());
});
});
};

我们可以在根目录新建babel.config.js来指定打包时的 babel 配置,当然也可以复用项目已有的配置。

babel.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
presets: [
[
'@babel/preset-env',
{
modules: false,
useBuiltIns: 'entry',
corejs: 3,
targets: {
chrome: 70,
},
},
],
],
};

最后,我们准备需要执行的 JS 入口文件,如果需要执行某些函数,可以将相关模块暴露到 window 对象,供 Puppeteer 使用。在这里我们将自己写的 sum 函数暴露到 window 上。

browser_run.js

1
2
3
4
5
import add from 'lodash/add';

const sum = (...param) => [...param].reduce((a, b) => add(a, b), 0);

window.sum = sum;

最终在 Puppeteer 中调用buildModule函数,传入入口文件路径,经由 webpack 打包和 babel 编译,最后通过page.evaluate函数执行。

1
2
3
4
5
6
7
8
9
10
11
const puppeteer = require('puppeteer');

(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
const scriptStr = await buildModule('./browser_run.js');
await page.evaluate(scriptStr);
await page.evaluate(() => {
console.log('sum:', window.sum(1, 2, 3, 4, 5));
});
})();

使用 Jest 测试前端代码

发表于 2019-09-21 | 更新于 2021-09-27 | 分类于 前端开发

随着 SPA 的兴起,前端的代码越来越复杂。有时也会根据业务需求写一些通用的前端框架。为了保证代码的健壮性和可维护性,测试就显得尤为重要了。在对比了市面上的前端框架后,决定以 Jest 为例,讨论下前端测试的方向和如何搭建测试环境。

测试的分类

  • 单元测试:测试一个函数或者类中的方法的执行是否符合预期行为,是测试的最小单元。可以与开发同时进行,避免一些简单的逻辑错误。
  • 集成测试:测试系统主要组成模块的功能是否正常,当多个函数或方法组合起来时能否按预期执行。
  • e2e 测试:测试系统在真实的环境中(网络,数据库等)模拟真实的用户场景,对应用各个功能进行完整的黑盒测试。
  • UI 测试:测试界面 UI 是否完全符合设计的要求,包含 UI 的交互和元素的位置和样式。通常可以使用快照(snapshot)进行测试。

编写测试的好处

  1. 单元测试可以在开发时期发现潜在的 BUG,测试数据边界情况。
  2. 改完 BUG 后,可以添加相应的测试做验证。
  3. 重构代码时保证逻辑正确,心里有底。
  4. 测试代码有时可以作为 API 的说明文档,提高合作开发效率。
  5. 在编写测试时,可以对代码有更深入的思考,提高编码水平。

配置 Jest 测试环境

最简单的使用方法仅三步

  1. yarn add jest --dev
  2. 编写测试文件,并命名为 xxx.test.js
  3. npx jest

这篇文章我们以 create-react-app 中集成的 Jest 为例。通过修改测试配置文件,支持测试的拆分和 Puppeteer 的集成,以更好的提升开发体验。

  1. 创建 CRA 项目 create-react-app jest-demo
  2. 弹出 CRA 的配置 yarn eject

这时我们可以看到 Jest 的配置主要都集中在了package.json文件中,初始配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{
"jest": {
"roots": ["<rootDir>/src"],
"collectCoverageFrom": ["src/**/*.{js,jsx,ts,tsx}", "!src/**/*.d.ts"],
"setupFiles": ["react-app-polyfill/jsdom"],
"setupFilesAfterEnv": [],
"testMatch": [
"<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
"<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
],
"testEnvironment": "jest-environment-jsdom-fourteen",
"transform": {
"^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
},
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$",
"^.+\\.module\\.(css|sass|scss)$"
],
"modulePaths": [],
"moduleNameMapper": {
"^react-native$": "react-native-web",
"^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"web.js",
"js",
"web.ts",
"ts",
"web.tsx",
"tsx",
"json",
"web.jsx",
"jsx",
"node"
],
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
]
}
}

在testMatch中定义了,查找测试文件的方法,可以看到现在的默认行为是查找__tests__文件夹中的文件和以spec.js或test.js为结尾的文件,要做到测试的拆分,我们只能将配置拆开,定义不同的testMatch。
为方便管理配置文件我们将配置文件的公共部分从package.json中移到config/jest中,并命名为base.config.js。完成后删除package.json中的 jest 配置。

base.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
module.exports = {
rootDir: process.cwd(),
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts'],
setupFiles: ['react-app-polyfill/jsdom'],
setupFilesAfterEnv: [],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)':
'<rootDir>/config/jest/fileTransform.js',
},
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$',
],
modulePaths: [],
moduleNameMapper: {
'^react-native$': 'react-native-web',
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
},
moduleFileExtensions: [
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
'node',
],
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname',
],
};

接下来我们拆分单元测试和 e2e 测试,并配置不同的入口。先新建unit.config.js作为单元测试配置的入口。

unit.config.js

1
2
3
4
5
6
const baseConfig = require('./base.config');

module.exports = Object.assign({}, baseConfig, {
testEnvironment: 'jest-environment-jsdom-fourteen',
testMatch: ['<rootDir>/src/**/__tests__/**/*.unit.test.{js,jsx,ts,tsx}'],
});

这样,我们就可以通过__tests__/*.unit.test.js定义单元测试文件了。
下面我们定义 e2e 测试的配置文件,并命名为e2e.config.js。
需要安装 Puppeteer 测试环境
yarn add puppeteer jest-puppeteer --dev
并修改 eslint 配置文件,添加 jest-puppeteer 的全局变量

package.json

1
2
3
4
5
6
7
8
9
10
11
{
"eslintConfig": {
"extends": "react-app",
"globals": {
"page": true,
"browser": true,
"context": true,
"jestPuppeteer": true
}
}
}

e2e.config.js

1
2
3
4
5
6
const baseConfig = require('./base.config');

module.exports = Object.assign({}, baseConfig, {
preset: 'jest-puppeteer',
testMatch: ['<rootDir>/e2e/**/*.{js,jsx,ts,tsx}'],
});

最后,我们修改package.json文件,增加测试脚本入口

1
2
3
4
5
6
7
8
{
"scripts": {
"start": "node scripts/start.js",
"build": "node scripts/build.js",
"test:unit": "node scripts/test.js --config config/jest/unit.config.js",
"test:e2e": "node scripts/test.js --config config/jest/e2e.config.js"
}
}

这样,我们就可以在 e2e 文件夹下编写 e2e 测试,并使用 Puppeteer 环境,具体使用方式可以参考 Github: jest-puppeteer。
同时,可以单独编写单元测试文件*.unit.test.js进行单元测试。两者可以独立运行,互不干扰。

  • 单元测试:yarn test:unit
  • e2e 测试:yarn test:e2e

同理,我们还可以尝试编写int.config.js配置文件,拆分集成测试,并通过yarn test:int运行。

最终 config 目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
config
├── env.js
├── jest
│ ├── base.config.js
│ ├── cssTransform.js
│ ├── e2e.config.js
│ ├── fileTransform.js
│ └── unit.config.js
├── modules.js
├── paths.js
├── pnpTs.js
├── webpack.config.js
└── webpackDevServer.config.js
FLY

FLY

关注最新前端技术
5 日志
2 分类
7 标签
京公网安备11010602007122号 京ICP备20011249号 © 2021 FLY
由 Hexo 强力驱动 v3.8.0
0%