yuxiaoliang

yuxiaoliang 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑

github博客主页:https://github.com/forthealll... 欢迎star和fork

今日头条前端招聘,微信必回,一对一沟通,微信yxllxyyxl,我们组上海or北京,

个人动态

yuxiaoliang 赞了文章 · 10月22日

一文了解文件上传全过程(1.8w字深度解析,进阶必备)

前言

平常在写业务的时候常常会用的到的是 GET, POST请求去请求接口,GET 相关的接口会比较容易基本不会出错,而对于 POST中常用的 表单提交,JSON提交也比较容易,但是对于文件上传呢?大家可能对这个步骤会比较害怕,因为可能大家对它并不是怎么熟悉,而浏览器Network对它也没有详细的进行记录,因此它成为了我们心中的一根刺,我们老是无法确定,关于文件上传到底是我写的有问题呢?还是后端有问题,当然,我们一般都比较谦虚, 总是会在自己身上找原因,可是往往实事呢?可能就出在后端身上,可能是他接受写的有问题,导致你换了各种请求库去尝试,axiosrequestfetch 等等。那么我们如何避免这种情况呢?我们自身要对这一块够熟悉,才能不以猜的方式去写代码。如果你觉得我以上说的你有同感,那么你阅读完这篇文章你将收获自信,你将不会质疑自己,不会以猜的方式去写代码。

本文比较长可能需要花点时间去看,需要有耐心,我采用自顶向下的方式,所有示例会先展现出你熟悉的方式,再一层层往下, 先从请求端是怎么发送文件的,再到接收端是怎么解析文件的。

前置知识

什么是 multipart/form-data?

multipart/form-data 最初由 《RFC 1867: Form-based File Upload in HTML》文档提出。

Since file-upload is a feature that will benefit many applications, this proposes an extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.

由于文件上传功能将使许多应用程序受益,因此建议对HTML进行扩展,以允许信息提供者统一表达文件上传请求,并提供文件上传响应的MIME兼容表示。

总结就是原先的规范不满足啦,我要扩充规范了。

文件上传为什么要用 multipart/form-data?

The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters. Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the values associated with a filled-out form from client to server.

1867文档中也写了为什么要新增一个类型,而不使用旧有的application/x-www-form-urlencoded:因为此类型不适合用于传输大型二进制数据或者包含非ASCII字符的数据。平常我们使用这个类型都是把表单数据使用url编码后传送给后端,二进制文件当然没办法一起编码进去了。所以multipart/form-data就诞生了,专门用于有效的传输文件。

也许你有疑问?那可以用 application/json吗?

其实我认为,无论你用什么都可以传,只不过会要综合考虑一些因素的话,multipart/form-data更好。例如我们知道了文件是以二进制的形式存在,application/json 是以文本形式进行传输,那么某种意义上我们确实可以将文件转成例如文本形式的 Base64 形式。但是呢,你转成这样的形式,后端也需要按照你这样传输的形式,做特殊的解析。并且文本在传输过程中是相比二进制效率低的,那么对于我们动辄几十M几百M的文件来说是速度是更慢的。

以上为什么文件传输要用multipart/form-data 我还可以举个例子,例如你在中国,你想要去美洲,我们的multipart/form-data相当于是选择飞机,而application/json相当于高铁,但是呢?中国和美洲之间没有高铁啊,你执意要坐高铁去,你可以花昂贵的代价(后端额外解析你的文本)造高铁去美洲,但是你有更加廉价的方式坐飞机(使用multipart/form-data)去美洲(去传输文件)。你图啥?(如果你有钱有时间,抱歉,打扰了,老子给你道歉)

multipart/form-data规范是什么?

摘自 《RFC 1867: Form-based File Upload in HTML》 6.Example

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x-- 

可以简单解释一些,首先是请求类型,然后是一个 boundary (分割符),这个东西是干啥的呢?其实看名字就知道,分隔符,当时分割作用,因为可能有多文件多字段,每个字段文件之间,我们无法准确地去判断这个文件哪里到哪里为截止状态。因此需要有分隔符来进行划分。然后再接下来就是声明内容的描述是 form-data 类型,字段名字是啥,如果是文件的话,得知道文件名是啥,还有这个文件的类型是啥,这个也很好理解,我上传一个文件,我总得告诉后端,我传的是个啥,是图片?还是一个txt文本?这些信息肯定得告诉人家,别人才好去进行判断,后面我们也会讲到如果这些没有声明的时候,会发生什么?

好了讲完了这些前置知识,我们接下来要进入我们的主题了。面对File, formData,Blob,Base64,ArrayBuffer,到底怎么做?还有文件上传不仅仅是前端的事。服务端也可以文件上传(例如我们利用某云,把静态资源上传到 OSS 对象存储)。服务端和客户端也有各种类型,Buffer,Stream,Base64....头秃,怎么搞?不急,就是因为上传文件不单单是前端的事,所以我将以下上传文件的一方称为请求端,接受文件一方称为接收方。我会以请求端各种上传方式,接收端是怎么解析我们的文件以及我们最终的杀手锏调试工具-wireshark来进行讲解。以下是讲解的大纲,我们先从浏览器端上传文件,再到服务端上传文件,然后我们再来解析文件是如何被解析的。

file-upload

请求端

浏览端

File

首先我们先写下最简单的一个表单提交方式。

<form action="http://localhost:7787/files" method="POST">
    <input name="file" type="file" id="file">
    <input type="submit" value="提交">
</form> 

我们选择文件后上传,发现后端返回了文件不存在。

image-20200328191433694

不用着急,熟悉的同学可能立马知道是啥原因了。嘘,知道了也听我慢慢叨叨。

我们打开控制台,由于表单提交会进行网页跳转,因此我们勾选preserve log 来进行日志追踪。

image-20200328191807526

image-20200328191733536

我们可以发现其实 FormDatafile 字段显示的是文件名,并没有将真正的内容进行传输。再看请求头。

image-20200328192020599

发现是请求头和预期不符,也印证了 application/x-www-form-urlencoded 无法进行文件上传。

我们加上请求头,再次请求。

<form action="http://localhost:7787/files" enctype="multipart/form-data" method="POST">
  <input name="file" type="file" id="file">
  <input type="submit" value="提交">
</form> 

image-20200328192539734

发现文件上传成功,简单的表单上传就是像以上一样简单。但是你得熟记文件上传的格式以及类型。

FormData

formData 的方式我随便写了以下几种方式。

<input type="file" id="file">
<button id="submit">上传</button>
<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script> submit.onclick = () => {
    const file = document.getElementById('file').files[0];
    var form = new FormData();
    form.append('file', file);
  
    // type 1
    axios.post('http://localhost:7787/files', form).then(res => {
        console.log(res.data);
    })
    // type 2
    fetch('http://localhost:7787/files', {
        method: 'POST',
        body: form
    }).then(res => res.json()).tehn(res => {console.log(res)});
    // type3
    var xhr = new XMLHttpRequest();
    xhr.open('POST', 'http://localhost:7787/files', true);
    xhr.onload = function () {
        console.log(xhr.responseText);
    };
    xhr.send(form);
} </script> 

image-20200328192539734

以上几种方式都是可以的。但是呢,请求库这么多,我随便在 npm 上一搜就有几百个请求相关的库。

image-20200328194431932

因此,掌握请求库的写法并不是我们的目标,目标只有一个还是掌握文件上传的请求头和请求内容。

image-20200328194625420

Blob

Blob 对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是JavaScript原生格式的数据。File 接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

因此如果我们遇到 Blob 方式的文件上方式不用害怕,可以用以下两种方式:

1.直接使用 blob 上传

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
    
const form = new FormData();
form.append('file', blob, '1.json');
axios.post('http://localhost:7787/files', form); 

2.使用 File 对象,再进行一次包装(File 兼容性可能会差一些 https://caniuse.com/#search=File)

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
    
const file = new File([blob], '1.json');
form.append('file', file);
axios.post('http://localhost:7787/files', form) 

ArrayBuffer

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。

虽然它用的比较少,但是他是最贴近文件流的方式了。

在浏览器中,他每个字节以十进制的方式存在。我提前准备了一张图片。

const bufferArrary = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,1,3,0,0,0,37,219,86,202,0,0,0,6,80,76,84,69,0,0,255,128,128,128,76,108,191,213,0,0,0,9,112,72,89,115,0,0,14,196,0,0,14,196,1,149,43,14,27,0,0,0,10,73,68,65,84,8,153,99,96,0,0,0,2,0,1,244,113,100,166,0,0,0,0,73,69,78,68,174,66,96,130];
const array = Uint8Array.from(bufferArrary);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form) 

这里需要注意的是 new Blob([typedArray.buffer], {type: 'xxx'}),第一个参数是由一个数组包裹。里面是 typedArray 类型的 buffer。

Base64

const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEUAAP+AgIBMbL/VAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==';
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
    byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const array = Uint8Array.from(byteNumbers);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form); 

关于 base64 的转化和原理可以看这两篇 base64 原理

原来浏览器原生支持JS Base64编码解码

小结

对于浏览器端的文件上传,可以归结出一个套路,所有东西核心思路就是构造出 File 对象。然后观察请求 Content-Type,再看请求体是否有信息缺失。而以上这些二进制数据类型的转化可以看以下表。

transform.77175c26

图片来源 (https://shanyue.tech/post/binary-in-frontend/#%E6%95%B0%E6%8D%AE%E8%BE%93%E5%85%A5)

服务端

讲完了浏览器端,现在我们来讲服务器端,和浏览器不同的是,服务端上传有两个难点。

1.浏览器没有原生 formData,也不会想浏览器一样帮我们转成二进制形式。

2.服务端没有可视化的 Network 调试器。

Buffer

Request

首先我们通过最简单的示例来进行演示,然后一步一步深入。相信文档可以查看 https://github.com/request/request#multipartform-data-multipart-form-uploads

// request-error.js
const fs = require('fs');
const path = require('path');
const request = require('request');
const stream = fs.readFileSync(path.join(__dirname, '../1.png'));
request.post({
    url: 'http://localhost:7787/files',
    formData: {
        file: stream,
    }
}, (err, res, body) => {
    console.log(body);
}) 

image-20200328234106276

发现报了一个错误,正像上面所说,浏览器端报错,可以用NetWork。那么服务端怎么办?这个时候我们拿出我们的利器 -- wireshark

我们打开 wireshark (如果没有或者不会的可以查看教程 https://blog.csdn.net/u013613428/article/details/53156957)

设置配置 tcp.port == 7787,这个是我们后端的端口。

image-20200328234316233

运行上述文件 node request-error.js

image-20200328234543643

我们来找到我们发送的这条http的请求报文。中间那堆乱七八糟的就是我们的文件内容。

POST /files HTTP/1.1
host: localhost:7787
content-type: multipart/form-data; boundary=--------------------------437240798074408070374415
content-length: 305
Connection: close

----------------------------437240798074408070374415
Content-Disposition: form-data; name="file"
Content-Type: application/octet-stream

.PNG
.
...
IHDR.............%.V.....PLTE......Ll.....  pHYs..........+.....
IDAT..c`.......qd.....IEND.B`.
----------------------------437240798074408070374415-- 

可以看到上述报文。发现我们的内容请求头 Content-Type: application/octet-stream有错误,我们上传的是图片请求头应该是image/png,并且也少了 filename="1.png"

我们来思考一下,我们刚才用的是fs.readFileSync(path.join(__dirname, '../1.png')) 这个函数返回的是 BufferBuffer是什么样的呢?就是下面的形式,不会包含任何文件相关的信息,只有二进制流。

<Buffer 01 02> 

所以我想到的是,需要指定文件名以及文件格式,幸好 request 也给我们提供了这个选项。

key: {
    value:  fs.createReadStream('/dev/urandom'),
    options: {
      filename: 'topsecret.jpg',
      contentType: 'image/jpeg'
    }
} 

可以指定options,因此正确的代码应该如下(省略不重要的代码)

...
request.post({
    url: 'http://localhost:7787/files',
    formData: {
        file: {
            value: stream,
            options: {
                filename: '1.png'
            }
        },
    }
}); 

我们通过抓包可以进行分析到,文件上传的要点还是规范,大部分的问题,都可以通过规范模板来进行排查,是否构造出了规范的样子。

Form-data

我们再深入一些,来看看 request 的源码, 他是怎么实现Node端的数据传输的。

打开源码我们很容易地就可以找到关于 formData 这块相关的内容 https://github.com/request/request/blob/3.0/request.js#L21

image-20200328235629308

就是利用form-data,我们先来看看 formData 的方式。

const path = require('path');
const FormData = require('form-data');
const fs = require('fs');
const http = require('http');
const form = new FormData();
form.append('file', fs.readFileSync(path.join(__dirname, '../1.png')), {
    filename: '1.png',
    contentType: 'image/jpeg',
});
const request = http.request({
    method: 'post',
    host: 'localhost',
    port: '7787',
    path: '/files',
    headers: form.getHeaders()
});
form.pipe(request);
request.on('response', function(res) {
    console.log(res.statusCode);
}); 
原生 Node

看完 formData,可能感觉这个封装还是太高层了,于是我打算对照规范手动来构造multipart/form-data请求方式来进行讲解。我们再来回顾一下规范。

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x-- 

我模拟上方,我用原生 Node 写出了一个multipart/form-data 请求的方式。

主要分为4个部分
  • 构造请求header
  • 构造内容header
  • 写入内容
  • 写入结束分隔符
const path = require('path');
const fs = require('fs');
const http = require('http');
// 定义一个分隔符,要确保唯一性
const boundaryKey = '-------------------------461591080941622511336662';
const request = http.request({
    method: 'post',
    host: 'localhost',
    port: '7787',
    path: '/files',
    headers: {
        'Content-Type': 'multipart/form-data; boundary=' + boundaryKey, // 在请求头上加上分隔符
        'Connection': 'keep-alive'
    }
});
// 写入内容头部
request.write(
    `--${boundaryKey}rnContent-Disposition: form-data; name="file"; filename="1.png"rnContent-Type: image/jpegrnrn`
);
// 写入内容
const fileStream = fs.createReadStream(path.join(__dirname, '../1.png'));
fileStream.pipe(request, { end: false });
fileStream.on('end', function () {
    // 写入尾部
    request.end('rn--' + boundaryKey + '--' + 'rn');
});
request.on('response', function(res) {
    console.log(res.statusCode);
}); 

至此,已经实现服务端上传文件的方式。

Stream、Base64

由于这两块就是和Buffer的转化,比较简单,我就不再重复描述了。可以作为留给大家的作业,感兴趣的可以给我这个示例代码仓库贡献这两个示例。

// base64 to buffer
const b64string = /* whatever */;
const buf = Buffer.from(b64string, 'base64'); 
// stream to buffer
function streamToBuffer(stream) {  
  return new Promise((resolve, reject) => {
    const buffers = [];
    stream.on('error', reject);
    stream.on('data', (data) => buffers.push(data))
    stream.on('end', () => resolve(Buffer.concat(buffers))
  });
} 

小结

由于服务端没有像浏览器那样 formData 的原生对象,因此服务端核心思路为构造出文件上传的格式(header,filename等),然后写入 buffer 。然后千万别忘了用 wireshark进行验证。

接收端

这一部分是针对 Node 端进行讲解,对于那些 koa-body 等用惯了的同学,可能一样不太清楚整个过程发生了什么?可能唯一比较清楚的是 ctx.request.files ??? 如果ctx.request.files 不存在,就会懵逼了,可能也不太清楚它到底做了什么,文件流又是怎么解析的。

我还是要说到规范...请求端是按照规范来构造请求..那么我们接收端自然是按照规范来解析请求了。

Koa-body

const koaBody = require('koa-body');

app.use(koaBody({ multipart: true })); 

我们来看看最常用的 koa-body,它的使用方式非常简单,短短几行,就能让我们享受到文件上传的简单与快乐(其他源码库一样的思路去寻找问题的本源) 可以带着一个问题去阅读,为什么用了它就能解析出文件?

寻求问题的本源,我们当然要打开 koa-body的源码,koa-body 源码很少只有211行,https://github.com/dlau/koa-body/blob/v4.1.1/index.js#L125 很容易地发现它其实是用了一个叫做formidable的库来解析 files 的。并且把解析好的 files 对象赋值到了 ctx.req.files。(所以说大家不要一味死记 ctx.request.files, 注意查看文档,因为今天用 koa-bodyctx.request.files 明天换个库可能就是 ctx.request.body 了)

因此看完 koa-body我们得出的结论是,koa-body的核心方法是formidable

Formidable

那么让我们继续深入,来看看formidable做了什么,我们首先来看它的目录结构。

.
├── lib
│   ├── file.js
│   ├── incoming_form.js
│   ├── index.js
│   ├── json_parser.js
│   ├── multipart_parser.js
│   ├── octet_parser.js
│   └── querystring_parser.js 

看到这个目录,我们大致可以梳理出这样的关系。

index.js
|
incoming_form.js
|
type
?
|
1.json_parser
2.multipart_parser
3.octet_parser
4.querystring_parser 

由于源码分析比较枯燥。因此我只摘录比较重要的片段。由于我们是分析文件上传,所以我们只需要关心 multipart_parser 这个文件。

https://github.com/node-formidable/formidable/blob/v1.2.1/lib/multipart_parser.js#L72

...
MultipartParser.prototype.write = function(buffer) {
    console.log(buffer);
  var self = this,
      i = 0,
      len = buffer.length,
      prevIndex = this.index,
      index = this.index,
      state = this.state,
... 

我们将它的 buffer 打印看看.

<Buffer 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 36 36 ... >
144
<Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 00 01 00 00 00 01 01 03 00 00 00 25 db 56 ca 00 00 00 06 50 4c 54 45 00 00 ff 80 80 80 4c 6c bf ... >
106
<Buffer 0d 0a 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 ... > 

我们来看 wireshark 抓到的包

image-20200329144355168

我用红色进行了分割标记,对应的就是formidable所分割的片段 ,所以说这个包主要是将大段的 buffer 进行分割,然后循环处理。

这里我还可以补充一下,可能你对以上表非常陌生。左侧是二进制流,每1个代表1个字节,1字节=8位,上面的 2d 其实就是16进制的表示形式,用二进制表示就是 0010 1101,右侧是ascii 码用来可视化,但是 assii 分可显和非可显示。有部分是无法可视的。比如你所看到文件中有需要小点,就是不可见字符。

你可以对照,ascii表对照表来看。

我来总结一下formidable对于文件的处理流程。

formible-process

原生 Node

好了,我们已经知道了文件处理的流程,那么我们自己来写一个吧。

const fs = require('fs');
const http = require('http');
const querystring = require('querystring');
const server = http.createServer((req, res) => {
  if (req.url === "/files" && req.method.toLowerCase() === "post") {
    parseFile(req, res)
  }
})
function parseFile(req, res) {
  req.setEncoding("binary");
  let body = "";
  let fileName = "";
  // 边界字符
  let boundary = req.headers['content-type']
    .split('; ')[1]
    .replace("boundary=", "")
  
  req.on("data", function(chunk) {
    body += chunk;
  });
  req.on("end", function() {
    // 按照分解符切分
    const list = body.split(boundary);
    let contentType = '';
    let fileName = '';
    for (let i = 0; i < list.length; i++) {
      if (list[i].includes('Content-Disposition')) {
        const data = list[i].split('rn');
        for (let j = 0; j < data.length; j++) {
          // 从头部拆分出名字和类型
          if (data[j].includes('Content-Disposition')) {
            const info = data[j].split(':')[1].split(';');
            fileName = info[info.length - 1].split('=')[1].replace(/"/g, '');
            console.log(fileName);
          }
          if (data[j].includes('Content-Type')) {
            contentType = data[j];
            console.log(data[j].split(':')[1]);
          }
        }
      }
    }
    // 去除前面的请求头
    const start = body.toString().indexOf(contentType) + contentType.length + 4; // 有多rnrn
    const startBinary = body.toString().substring(start);
    const end = startBinary.indexOf("--" + boundary + "--") - 2; // 前面有多rn
     // 去除后面的分隔符
    const binary = startBinary.substring(0, end);
    const bufferData = Buffer.from(binary, "binary");
    fs.writeFile(fileName, bufferData, function(err) {
      res.end("sucess");
    });
    ;
  })
}

server.listen(7787) 

总结

相信有了以上的介绍,你不再对文件上传有所惧怕, 对文件上传整个过程都会比较清晰了,还不懂。。。。找我。

再次回顾下我们的重点:

请求端出问题,浏览器端打开 network 查看格式是否正确(请求头,请求体), 如果数据不够详细,打开 wireshark,对照我们的规范标准,看下格式(请求头,请求体)。

接收端出问题,情况一就是请求端缺少信息,参考上面请求端出问题的情况,情况二请求体内容错误,如果说请求体内容是请求端自己构造的,那么需要检查请求体是否是正确的二进制流(例如上面的blob构造的时候,我一开始少了一个[],导致内容主体错误)。

其实讲这么多就两个字: 规范,所有的生态都是围绕它而展开的。更多请看我的博客

系列文章

一文带你层层解锁「文件下载」的奥秘_蓝色的秋风 - SegmentFault 思否

⚡️前端多线程大文件下载实践,提速10倍,拿捏百度云盘_蓝色的秋风 - SegmentFault 思否

最后

如果我的文章有帮助到你,希望你也能帮助我,欢迎关注我的微信公众号 秋风的笔记,回复好友 二次,可加微信并且加入交流群,秋风的笔记 将一直陪伴你的左右。

image

参考

https://juejin.im/post/6844903810079391757

https://my.oschina.net/bing309/blog/3132260

https://segmentfault.com/a/1190000020654277

查看原文

赞 58 收藏 41 评论 0

yuxiaoliang 发布了文章 · 4月27日

通过webgl中的纹理贴图来自定义图片间的转场效果

通过webgl中的纹理贴图来自定义图片间的转场效果


    本章节建立我们基本上掌握了如何在2D或者3D平面中绘制图形的基础上(当然不了解2D和3D平面绘制图片也可以往下阅读),我们在2D平面中,可以用webgl去绘制我们所需要的图案,但实际中我们用到某个图形时不可能全部一一的去绘制,纹理贴图解决了这个问题,我们可以用已有的图片来填充webgl中的图形。

  • 纹理基础
  • 2D图形的纹理贴图
  • 多纹理单元的纹理贴图
  • 不同纹理间实现转场特效

原文的地址来自我的博客:https://github.com/forthealll...

这个系列的源码地址为:源码的地址为: https://github.com/forthealll...

一、纹理基础

    在了解纹理贴图之前,我们先来了解一下预备知识,首先什么是纹理贴图或者说纹理映射呢?

    将一张图像贴到一个几何体的表面

    这就是纹理贴图的含义,这张图像我们就成为纹理,这个工作我们就称为纹理贴图,其本质就是提取图像中的颜色,然后对应的赋予给几何平面的某个位置,从而将图像在几何体表面完成渲染。

    这里从纹理(图像)到几何体表面之间有一个映射,为了了解这个映射是怎么发生的,我们必须了解一下纹理坐标和裁剪坐标。

裁剪坐标

    webgl中的裁剪面坐标如下所示:

Lark20191216-181530

    从上述裁剪坐标系统的示意图中我们可以看出,webgl中整个裁剪平面的中心坐标是(0,0),对于二维的裁剪平面而言其水平方向从(-1,0)到(1,0),其竖直方向从(0,-1)到(0,1).

也就是说其裁剪坐标任何方向的值在区间[-1,1]内。

注意一点:裁剪平面是决定如何映射到画布上,因为画布是二维的,因此裁剪平面也是二维的。webgl中z轴的坐标并不限制在(-1,1),可以为任何值

纹理坐标

    纹理坐标跟裁剪坐标不一样,其值不是从[-1,1]而是从[0,1]:

Lark20191230-195608

在贴图过程中,我们需要使裁剪坐标中的顶点坐标,与纹理坐标系统点一一对应。也就是如何截取纹理坐标中的纹理,贴到几何图形中。

需要注意的是,图片本身的坐标与纹理的坐标是左右相等,上下相反的,因此,存在一个上下方向的坐标变换。在webgl中的纹理 中,通过:

 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,1)

来反转Y轴上的方向。

禁用Chrome的安全性检测

    本文的例子中,直接本地通过file文件打开html,如果在这个html文件中有ajax跨域请求,那么因为浏览器的安全性检测,会提示如下信息的错误:

Cross origin requests are only supported for HTTP....

当然如果启动一个本地server就不会有影响。这里有种懒人解决方案,就是通过禁用安全性检测的方法,以mac为例,从命令行窗口中启动chrome,启动命令为

open /Applications/Google\ Chrome.app --args --allow-file-access-from-files

此外,也可以安装http-server,快速在本地启一个server.

二、2D图形的纹理贴图

    下面我们以2D几何图形的纹理贴图来介绍一下,在webgl中如何实现纹理贴图。

(1).首先创建一个纹理缓冲

function createTexture (gl, filter, data, width, height) {
  let textureRef = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, textureRef);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,1)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, data);
  return textureRef;
}

    上述就是一个创建纹理缓存的例子,通过gl.createTexture()创建一个纹理缓存,并关联系统变量gl.TEXTURE_2D. 此外,因为在纹理的渲染中,在Y轴方向与图片是完全相反的,因此如果需要正相呈现纹理渲染结果,比如进行Y轴方向的反转,即gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,1)。

    此外我们可以通过gl.texParameteri函数,指定当图片纹理大于渲染区域,或者图片纹理小于渲染区域时,如何去正确的渲染。

    最后通过:

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, data);

    提取data中的元素保存到纹理缓存中,data可以是image,也可以是video,甚至是另一个canvas的渲染结果。也就是说,webgl的纹理贴图的源,不仅仅可以是图片,还可以是视频的一帧,甚至是另一个cavans

(2).加载图片使用纹理缓冲

 let image = new Image()
 image.src = './cubetexture.png'
 image.onload = function(){
    let textureRef  = createTexture(gl,gl.LINEAR,image1);
    gl.activeTexture(gl.TEXTURE0)
    gl.bindTexture(gl.TEXTURE_2D,textureRef)
    gl.uniform1i(u_Sampler, 0);  // texture unit 0
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  }

上述代码加载image图片,在onload的时候,通过之前定义的createTexture创建纹理,然后激活使用0号纹理单元,webgl可以同时支持多个纹理单元,可以同时将多个纹理渲染到同一个可视区。激活纹理单元后,我们将0传递给着色器中的取样器变量u_Sampler.

最后我们在片元着色器中接受纹理单元编号,以及纹理坐标,最后贴图渲染到渲染区:

    uniform sampler2D u_Sampler; //取色器变量
    varying lowp vec2 v_TexCoord; //纹理坐标
    void main() {
      gl_FragColor = texture2D(u_Sampler,v_TexCoord); 
    }

我们最终得到了在所指定的区域,渲染了正确的一张图片,渲染结果如下所示:

Lark20200426-211044

三、多纹理单元的纹理贴图

    前面说到webgl可以同时支持多个纹理单元,webgl可以同时处理多幅纹理,纹理单元就是为了这个目的而设计的。在上述的例子中,我们只用了一个纹理单元,将一张纹理渲染到了渲染区。接下来我们尝试使用两个纹理单元,将两张图片,渲染到同一个区域。

<div align=center>
<img data-original="https://user-images.githubusercontent.com/17233651/80330488-df79ab00-8877-11ea-8f2c-8faac87835de.png" width=256 height=256 /><br/>
纹理图片1
</div>

<div align=center>
<img data-original="https://user-images.githubusercontent.com/17233651/80331074-a6423a80-8879-11ea-9251-07a2b0323f1f.png" width=256 height=256 /><br/>
纹理图片2
</div>

    我们使用webgl中国的两个纹理单元,分别为gl.TEXTURE0和gl.TEXTURE1,修改代码如下。

首先是加载和渲染的逻辑:

  let textures = []
  image.onload = function(){
     let textureRef  = createTexture(gl,gl.LINEAR,image);
     gl.activeTexture(gl.TEXTURE0);
     gl.bindTexture(gl.TEXTURE_2D,textureRef)
     textures.push(textureRef)    
  }
  image.src = './cubetexture.png'
  let image1 = new Image();
  image1.onload = function(){
    let textureRef1  = createTexture(gl,gl.LINEAR,image1);
    gl.activeTexture(gl.TEXTURE1)
    gl.bindTexture(gl.TEXTURE_2D,textureRef1)
    textures.push(textureRef1)
  }
  image1.src = './img.png'
  
  //激活纹理单元并且绘制
  gl.uniform1i(u_Sampler, 0);  // texture unit 0
  gl.uniform1i(u_Sampler1, 1);  // texture unit 1
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, textures[0]);
  gl.activeTexture(gl.TEXTURE1);
  gl.bindTexture(gl.TEXTURE_2D, textures[1]);

最后修改片元着色器:

uniform sampler2D u_Sampler;
uniform sampler2D u_Sampler1;
void main(){
  gl_FragColor = texture2D(u_Sampler,v_TexCoord) + texture2D(u_Sampler1,v_TexCoord); 
}

最后的渲染结果为:

<div align=center>
<img data-original="https://user-images.githubusercontent.com/17233651/80331830-ba873700-887b-11ea-9784-d01b9dc2274d.png" width=256 height=256 /><br/>
最后双纹理单元合成渲染结果
</div>

完成的代码 地址为:https://github.com/forthealll...

四、不同纹理间实现转场效果

    通过使用多纹理单元,我们可以实现图片间的转场效果,首先我们来看什么是转场效果。

    转场效果顾名思义,就是从图片A切换到图片B之间的动画特效,类似与我们在做PPT的时候,一些淡入淡出等等动效。在webgl中,渲染的纹理本质也是图片,因此可以在不同纹理间,按时间顺序控制不同纹理的渲染结果,可以实现转场的效果。

<img data-original="https://camo.githubusercontent.com/c42ecc6197b0f51a106fb50723f9bc6d2e1f925c/687474703a2f2f692e696d6775722e636f6d2f74573331704a452e676966" /><img data-original="https://camo.githubusercontent.com/7e34cd12d5a9afa94f470395b04b0914c978ce01/687474703a2f2f692e696d6775722e636f6d2f555a5a727775552e676966" /><img data-original="https://camo.githubusercontent.com/0456d4ed8753fbce027f1174dc8b22da548eeade/687474703a2f2f692e696d6775722e636f6d2f654974426a33582e676966" />

如上所示的动图就是列出了一些转场特效。

    我们以同样前面的两张图片为例,研究一下图片或者说纹理间的转场效果。

直接看片元着色器中的代码:

vec4 transition (vec2 uv) {
  float time = progress;
  float stime = sin(time * PI / 2.);
  float phase = time * PI * bounces;
  float y = (abs(cos(phase))) * (1.0 - stime);
  float d = uv.y - y;
  return mix(
    mix(
      getToColor(uv),
      shadow_colour,
      step(d, shadow_height) * (1. - mix(
        ((d / shadow_height) * shadow_colour.a) + (1.0 - shadow_colour.a),
        1.0,
        smoothstep(0.95, 1., progress) // fade-out the shadow at the end
      ))
    ),
    getFromColor(vec2(uv.x, uv.y + (1.0 - y))),
    step(d, 0.0)
  );
}

    我们看到这个transition函数就是一个转换函数,决定了随着时间,如何切换纹理,从而进行渲染,我们通过getFromColor和getToColor指定了两个不同纹理单元的纹理:

    vec4 getToColor(vec2  uv){
      return texture2D(u_Sampler,uv);
    }
    vec4 getFromColor(vec2 uv){
      return texture2D(u_Sampler1,uv);
    }

最后片元着色器的颜色就是调用transition函数后的结果,在调用transition的时候我们传入了纹理坐标。

void main() {
    gl_FragColor =  transition(v_TexCoord);
}

最后要想要渲染结果动起来,就必须写一个定时器,动态改变transition中的参数progress,是其从0到1的变化。


setInterval(()=>{
    if(textures.length === 2){
      if(i >= 1){
        i = 0.01
      }
      gl.uniform1i(u_Sampler, 0);  // texture unit 0
      gl.uniform1i(u_Sampler1, 1);  // texture unit 1
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, textures[0]);
      gl.activeTexture(gl.TEXTURE1);
      gl.bindTexture(gl.TEXTURE_2D, textures[1]);
      let progress = gl.getUniformLocation(shaderProgram,'progress')
      gl.uniform1f(progress,i)
      i += 0.05
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    
    }
  },100)

最后的渲染结果我们可以看到的动画结果如下:

Untitled1

完成的代码 地址为:https://github.com/forthealll...

此外,在https://github.com/gl-transit... 上收录了各色各样的转场效果,转场不仅仅可以应用于图片,还可以应用于视频的不同帧间,下篇文章将具体讲讲如何实现在webgl中渲染video,以及video的帧间动画。

查看原文

赞 2 收藏 2 评论 0

yuxiaoliang 发布了文章 · 2019-12-10

优雅的学习webgl(2)—webgl中的着色器和缓冲区


    在之前一章中我们了解了webgl中的一些常识,也尝试绘制了第一个webgl程序,接下来这一张会介绍一些webgl中的基本概念,包含顶点着色器、片元着色器、缓冲区

  • 什么是顶点着色器和片元着色器
  • 缓冲区

这个系列的源码地址为:源码的地址为: https://github.com/forthealll...

一、什么是顶点着色器和片元着色器

    我们在第一篇文章中初略的介绍了着色器,在这里我们在详细讲讲什么是着色器。

1.顶点着色器

    顶点着色器保存了所想绘制的图形的顶点的信息,包括了顶点的坐标,以及顶点的大小。我们结合GLSL语言,来详细的讲讲顶点着色器。

attribute vec4 a_position;
 
void main() {
   gl_Position = a_position;
   gl_PointSize = 10.0;
}

    顶点着色器基本上具有两个系统的变量gl_Position和gl_PointSize,分别表示顶点的位置和顶点的大小。并且图形中的每一个顶点都会调用一次顶点着色器。顶点着色器可以使用attribute来修饰的全局变量。

上述例子中的:

attribute vec4 a_position;

    a_position变量就是一个attribute修饰符修饰的全局变量,其变量的值可以在其他地方被赋值。我们可以在我们的js代码中:

var positionLoc = gl.getAttribLocation(someShaderProgram, "a_position");

    通过gl实例来获取某个程序对象上的attribute修饰符修饰的变量a_position的地址,也可以给这个a_position变量赋新的值。

gl.vertexAttribPointer(positionLoc,newValue)

这里的全局变量听起来很拗口,其实全局的意思就是:

不仅仅只能在着色器中被使用,可以在着色器外被捕获或者谁用的变量,在GLSE语言中都是全局变量。

在顶点着色器中使用的全部变量除了用attribute修饰外,还可以通过uniform来修饰。比如我们需要在移动某个图形(给某个图形的每个顶点一个偏移)

//顶点着色器
attribute vec4 a_position;
uniform vec4 u_offset;
 
void main() {
   gl_Position = a_position + u_offset;
}
//顶点着色器以外的逻辑代码
var offsetLoc = gl.getUniformLocation(someProgram, "u_offset");
gl.uniform4fv(offsetLoc, [1, 0, 0, 0]);  // 向右偏移一半屏幕宽度

    我们通过uniform这个全局变量,给顶点着色器赋值了一个偏移量。

在顶点着色器中attribute修饰的变量和uniform修饰的变量都可以作为全局变量在着色器外被取值和赋值。那么两者之前有什么区别?

attribute修饰的全局变量可以读取缓冲区中的值

至于什么是缓冲区,我们下节中会具体降到,简单来说缓冲区的存在使得可以在一次绘制中绘制出图形所有的顶点,而不需要手动的逐顶点绘制.

2.片元着色器

    在前面一章中我们讲到片元着色器决定了图形的颜色,那么片元着色器是如何动作的呢。简单来说:

从顶点着色器中了如何装配图形,当顶点着色器画出了图形的轮廓后,片元着色器将图形分解成一个个小的片段,并且确定每一个片段的颜色。最后渲染出结果。

    其实从顶点着色器到片元着色器,然后从片元着色器到渲染到浏览器的过程比较复杂,具体可以分为如下几步:

  • 初始化顶点着色器
  • 根据顶点着色器中的顶点值,开始图形装配
  • 图形装配完成后,开始光栅化
  • 光栅化后每一个片元都可以用内置变量gl.FragCoord来表示
  • 从片元(Frag)相对应的gl.FragCoord中读取信息并对每个片元生成独立的颜色信息gl.FragColor,并保存到颜色缓冲区
  • 当所有的片元都有有颜色信息后,将颜色缓冲区的图形渲染到浏览器中

    用文字表述可能不够直观,我们来用图形表示:

Lark20191206-202215

    我们以渲染一个正方形为例,在顶点着色器中我们只给出了4个顶点的坐标,光栅化后生成了9个小片元,光栅化后的小片元进入片元着色器中被处理,最后渲染到浏览器中。

    我们在上一节中发现在片元着色器中我们仅仅传入了4个顶点的颜色,但是通过片元着色器,最后渲染出来的图形是一个彩色的正方形。

                Lark20191206-203133

    我们从顶点着色器中可以传递4个顶点的颜色信息给片元着色器,片元着色器接受这4个顶点的颜色信息,通过内插可以拿到每个片元的信息,最后渲染到浏览器中。

    我们来了解一下内插的过程,首先我们可以通过varying修饰的变量将值从顶点着色器传递到片元着色器。

//顶点着色器

attribute vec4 a_color
varying vec4 v_color
void main() {
   v_color = a_color
}

//片元着色器

varying vec4 v_color
void main(){
   gl.FragColor = v_color
}

    我们通过声明两个同名的用varying修饰的变量v_color,就可以实现将值从顶点着色器传递到片元着色器。

    为什么我们需要将值从顶点传递到片元着色器呢?

因为片元着色器的颜色信息等可能与顶点的坐标有关,此外顶点着色器是唯一可以从缓存中读取值的地方.

我们接着来看插值的过程示意图:

Lark20191209-172415

内插发生在光栅化的时候,可以根据顶点的颜色值,内插得到所有片元的颜色值。简单距离比如两个顶点的颜色分别是(0,0,0)和(0,0,1),两个顶点间有10个片元,那么内插得到的几个片元的颜色分别为(0,0,0.1),(0,0,0.2)...(0,0,0.9)

    总之:通过顶点着色器和片元着色器我们就能绘出各种图案,顶点着色器和片元着色器是webgl绘图的基础.

二、缓冲区

    缓冲区的作用很简单,就是缓冲图形的信息,在一次绘制中将图形绘制出来。我们接下来一步步来看缓冲区,我们前面画了一个点:

const vsSource = `
    attribute vec4 a_Position;
    void main() {
      gl_Position = a_Position;
      gl_PointSize = 10.0;
    }
  `;

  // Fragment shader program

  const fsSource = `
    void main() {
      gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
    }
  `;
  const shaderProgram = initShaderProgram(gl, vsSource, fsSource)
  gl.clearColor(0.0, 0.0, 0.0, 1.0);  // Clear to black, fully opaque
  gl.clearDepth(1.0);                 // Clear everything
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
  gl.useProgram(shaderProgram);
  //开始绘制
  a_Position = gl.getAttribLocation(shaderProgram,'a_Position')
  gl.vertexAttrib3f(a_Position,0.0,0.0,0.0);
  gl.drawArrays(gl.POINTS,0,1);

上述代码的运行结果为:

Lark20191209-175444

如果我们要绘制3个点,那么需要修改上述的代码:

//开始绘制
  a_Position = gl.getAttribLocation(shaderProgram,'a_Position')
  gl.vertexAttrib3f(a_Position,0.0,0.0,0.0);
  gl.drawArrays(gl.POINTS,0,1);

  a_Position = gl.getAttribLocation(shaderProgram,'a_Position')
  gl.vertexAttrib3f(a_Position,0.0,0.1,0.0);
  gl.drawArrays(gl.POINTS,0,1);

  a_Position = gl.getAttribLocation(shaderProgram,'a_Position')
  gl.vertexAttrib3f(a_Position,0.0,-0.1,0.0);
  gl.drawArrays(gl.POINTS,0,1);

我们需要连续绘制3次才能出现3个点,绘制的结果如下图所示:

Lark20191209-182151

    对于复杂的图形,我们不可能一个点一个点的去绘制,如果我们需要一次性的绘制多个点,那么就需要使用缓冲区,我们可以读取缓冲区内的顶点信息,然后一次性的绘制出来。

    缓冲区是webgl系统中的一块存储区,可以在缓冲区对象保存绘制图形所需要的顶点信息。

使用缓冲区需要一下的五个步骤:

  1. 创建缓冲区(gl.createBuffer())
  2. 绑定缓冲区(gl.bindBuffer())
  3. 将数据写入缓冲区对象(gl.bufferData())
  4. 将缓冲区对象分配给一个attribute变量(gl.vertexAttribPointer())
  5. 开启attribute变量(gl.enableVertexAttribArray())

    遵循上述的5个步骤,我们来使用缓冲区一次性绘制三个点,修改上面的代码:

  const shaderProgram = initShaderProgram(gl, vsSource, fsSource)
  gl.clearColor(0.0, 0.0, 0.0, 1.0);  // Clear to black, fully opaque
  gl.clearDepth(1.0);                 // Clear everything
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
  gl.useProgram(shaderProgram);
  //开始绘制
  let vertices = new Float32Array([0.0,0.0,0.0,0.1,0.0,-0.1])  ---(1)
  //创建缓冲区
  let vertexBuffer = gl.createBuffer()                 ----(2)
  //将缓冲区对象绑定到目标
  gl.bindBuffer(gl.ARRAY_BUFFER,vertexBuffer)          ----(3)
  //向缓冲区对象写入数据
  gl.bufferData(gl.ARRAY_BUFFER,vertices,gl.STATIC_DRAW)      ---(4)
  let a_Position = gl.getAttribLocation(shaderProgram,'a_Position')
  //将缓冲区对象分配给a_Position
  gl.vertexAttribPointer(a_Position,2,gl.FLOAT,false,0,0)     ---(5)
  //开启attribute变
  gl.enableVertexAttribArray(a_Position);
  gl.drawArrays(gl.POINTS,0,3)

上述的代码同样的是绘制3个点,但是是在一次绘制中就完成了。具体的代码可以查看:https://github.com/forthealll...

来看几个需要注意的地方,首先在(1)中的Float32Array是一个类型化数组,类型化数组是JavaScript操作二进制数据的一个接口,并不需要引入其他任何插件。
webgl中需要浏览器和显卡进行通信,为了满足JavaScript与显卡之间大量的、实时的数据交换,它们之间的数据通信必须是二进制的,而不能是传统的文本格式。像C语言那样,直接操作字节,然后将4个字节的32位整数,以二进制形式原封不动地送入显卡,脚本的性能就会大幅提升。

 let vertices = new Float32Array([0.0,0.0,0.0,0.1,0.0,-0.1])

此外就是将缓冲区对象分配给a_Position的(5)方法gl.vertexAttribPointer,该方法的接受6个参数,具体参数的意思这里不会去讲,值得注意的是这个方法将缓冲区的值传递给了顶点着色器中的attribute变量,并声明了如何在缓冲区中依次如何取值的方法。

 gl.vertexAttribPointer(a_Position,2,gl.FLOAT,false,0,0)

    这里这6个参数的意思是,缓冲区的attribute变量的引用a_Position,每次从缓冲区取2个点,缓冲区值的类型是FLOAT类型,false表示不会归一化,0表示从缓冲区数组的第0数组开始取,最后一个0表示间隔为0.

    上述就是缓冲区的做用,上述的缓冲区只保存了顶点信息,实际上缓冲区可以保存顶点的坐标信息,颜色信息,光照信息,矩阵变换信息等等,同时缓冲区也可以有多个,顶点着色器可以使用attribute变量来读取缓冲区的值,同时片元着色器可以通过varying变量来接受从顶点着色器传递过来的值,从而在依次绘制中就可以得到完整的结果。

    最后总结一下缓冲区和顶点着色器、片元着色器之间的通信:

Lark20191209-203239

查看原文

赞 1 收藏 0 评论 0

yuxiaoliang 发布了文章 · 2019-12-04

优雅的学习webgl(1)—从0开始构造你的第一个webgl程序


    学习webgl也有小半年的时间了,有了一些心得和体会,在这里做一个记录,整个系列的代码都会给出,这篇文章是这个系列的第一篇文章,带你走进webgl的世界。

  • 什么是webgl
  • 用webgl画点
  • 用webgl实现一个彩色正方形

这是一个系列的文章,首发自我的博客:https://github.com/forthealll...

这个系列的源码地址为:源码的地址为: https://github.com/forthealll...

一、什么是webgl

  在介绍什么是webgl之前,我们来看一个最简单的webgl程序。


<html lang="en">
  <head>
    <title>WebGL Demo</title>
    <meta charset="utf-8">
  </head>

  <body>
    <canvas id="glcanvas" width="640" height="480"></canvas>
  </body>

  <script>
    main();
    // start here
    function main() {
      const canvas = document.querySelector("#glcanvas");
      // Initialize the GL context
      const gl = canvas.getContext("webgl");
      // Only continue if WebGL is available and working
      if (!gl) {
        alert("你的浏览器不支持webgl");
        return;
      }

      // Set clear color to black, fully opaque
      gl.clearColor(0.0, 0.0, 0.0, 1.0);
      // Clear the color buffer with specified clear color
      gl.clear(gl.COLOR_BUFFER_BIT);
    }
  </script>
</html>

上述就是一个webgl的例子,做的事情很简单就是清空了webgl画布的颜色。在上述的例子中,我们没有引入什么其他的插件,就构建了一个webgl程序,因为webgl本身就是浏览器层面的。Webgl是内嵌在浏览器中的,你不必安装任何插件或者库,如果浏览器支持webgl,就可以通过getContext的方法创建一个webgl实例。

const gl = canvas.getContext("webgl");

    一提到webgl就容易跟3D渲染绘图联系在一起,实际上这两者并没有绝对关联,webgl也可以用来绘制2D平面图形,2D动画等等。那么webgl到底是什么呢?一句话概括就是:

通过绘图渲染技术OpenGL在浏览器里面进行图形渲染的技术

    了解过图形渲染技术的同学都听过OpenGL等,我们可以通过C语言,在window等平台上编写复杂和渲染出复杂的图形,通过OpenGL绘制和渲染图形有很高的平台要求以及编程语言的限制。而Webgl源自于OpenGL,从OpenGL2.0的着色器行为中诞生了适用于移动式穿戴设备的OpenGL ES标准,在这个标准下演化出了webgl,使得我们可以通过结合javascript语言和GLSL ES着色器语言,在浏览器中绘制出复杂的图形,不需要编译也不需要引入任何插件.

二、用webgl画点

    接着我们来看如何用webgl来绘制一个完整的图案,绘制图案跟上一小节最简单的清空画布的webgl程序不同。我们需要webgl中的两个重要的概念——着色器。
    webgl的本质就是通过顶点着色器和片元着色器,将图形渲染到浏览器中。之后我们会详细的介绍顶点着色器和片元着色器,这里可以简单的理解为顶点着色器决定了每个顶点的位置,片元着色器决定了图形的颜色。

1、初始化着色器程序

    绘制图案之前必须进行着色器程序初始化,根据着色球类型创建着色器对象,将着色器对象编译后绑定到程序对象,最后编译和连接程序对象从而完整了初始化的过程,接下来就可以使用程序对象来绘制图形。

    上面的两段话特别绕,总结就是:

GLSL着色器语言是以字符串的形式存在浏览器中的,为了能够将字符串编译成可以在显卡中运行的着色器程序,必须进行着色器程序初始化

在初始化着色器程序中我们必须用到两个对象着色器对象和程序对象,我们来简单介绍一下这两者。

  • 着色器对象: 我们前面提到了顶点着色器和片元着色器,着色器对象就是管理顶点着色器或者片元着色器的对象
  • 程序对象: 程序对象则是管理着色器对象的容器

我们用图来区别这两者的关系:

Lark20191204-145309

从这个图可以看出层级关系,我们要初始化着色器程序,就是按照如下的层级关系依次来初始化。

  1. 创建着色器对象(gl.createShader())
  2. 向着色器对象中填充着色器(gl.shaderSource())
  3. 编译着色球(gl.compileShader())
  4. 创建程序对象(gl.createProgram())
  5. 为程序对象分配着色器(gl.attashShader())
  6. 连接程序对象(gl.linkProgram())
  7. 使用程序对象(gl.useProgram())

结合上面图形的层次结构,以及上述的7步初始化着色器的步骤,我们可以来介绍着色器初始化函数initShaderProgram

function initShaderProgram(gl, vsSource, fsSource) {
  const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); //创建顶点着色器对象
  const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);//创建片元着色器对象

  // Create the shader program

  const shaderProgram = gl.createProgram();
  gl.attachShader(shaderProgram, vertexShader);
  gl.attachShader(shaderProgram, fragmentShader);
  gl.linkProgram(shaderProgram);

  return shaderProgram;
}

这就是最上层的程序对象的创建过程,至于着色器对象的创建可以通过如下函数
loadShader:

function loadShader(gl, type, source) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);

  return shader;
}

上述我们就完成了着色器程序的初始化,就可以在浏览器中编译GLSL ES语言渲染出图形。下面我们来看最简单的画点和三角形

2、画一个点

    我们上一小节将了如何初始化着色器,初始化着色器可以通过initShaderProgram方法,我们可以用webgl来画一个最简单的size为10的点

首先通过字符串的方式定义顶点着色器和片元着色器:

const vsSource = `
    ashouttribute vec4 aVertexPosition;

    uniform mat4 uModelViewMatrix;
    uniform mat4 uProjectionMatrix;

    void main() {
      gl_Position = vec4(0.0,0.0,0.0,1.0);
      gl_PointSize = 10.0;
    }
  `;

  // Fragment shader program

  const fsSource = `
    void main() {
      gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
    }
  `;

然后将这两个字符串传入initShaderProgram程序,就完成了着色器初始化。

const shaderProgram = initShaderProgram(gl, vsSource, fsSource)

最后清空画布,并使用这个着色器程序开始画图:

  gl.clearColor(0.0, 0.0, 0.0, 1.0);  
  gl.clearDepth(1.0);               
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
  gl.useProgram(shaderProgram);
  gl.drawArrays(gl.POINTS,0,1);

这样就在浏览器中画出了一个点。

Lark20191204-154158

源码的地址为: https://github.com/forthealll...

三、用webgl实现一个彩色正方形(选择性阅读)

    最后我们来实现一个较为复杂的例子,用webgl来画一个三角形。我们可以通过3次每次画一个点,画3个点,然后将这3个点连起来就成为了一个三角形,此外如果我们要一次性的画出3个点并连成三角形,就需要使用缓冲区。

我们可以简单理解,缓冲区保存了很多信息,我们可以读取缓冲区的信息,在一次绘制中绘出我们想要的图形。

我们来看创建一个缓冲区的步骤:

  1. 创建缓冲区对象(gl.createBuffer())
  2. 绑定缓冲区对象(gl.bindBuffer())
  3. 将数据写入缓冲区对象(gl.bufferData())
  4. 将缓冲区对象分配给一个attribute变量(gl.vertexAttribPointer())
  5. 开启attribute变量(gl.enableVertexAttribArray())

这个五步可以简记为:创建-绑定-写入-分配-开启这么几步,并且我们要使用缓冲区对象这五步是必不可免的.

我们可以定义initBuffers方法来创建缓冲区,我们可以分别创建一个顶点缓冲区(用于读取图形的顶点坐标)以及片元缓冲区(用于读取每个片元的颜色信息)

function initBuffers(gl) {
  //顶点缓冲区
  const positionBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);

  const positions = [
     1.0,  1.0,
    -1.0,  1.0,
     1.0, -1.0,
    -1.0, -1.0,
  ];

  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
  var colors = [
    1.0,  1.0,  1.0,  1.0,    // white
    1.0,  0.0,  0.0,  1.0,    // red
    0.0,  1.0,  0.0,  1.0,    // green
    0.0,  0.0,  1.0,  1.0,    // blue
  ];
 //颜色缓冲区
  const colorBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STATIC_DRAW);

  return {
    position: positionBuffer,
    color: colorBuffer,
  };
}

然后,在绘图的时候,通过:

gl.bindBuffer();
gl.vertexAttribPointer();
gl.enableVertexAttribArray()

来分别使用缓冲区,除了缓冲区外,我们还需要在顶点着色器和片元着色器中定义全局变量,来接受从缓冲区的传递过来的值。这里先省略,后期在详细介绍缓冲区和着色器的时候会提到.

最后给出绘制出来的图像:

Lark20191204-162205

源码的地址为: https://github.com/forthealll...

    最后补充一下,在第二张的画一个点的例子中,我们机会没有引入任何插件,实际上虽然webgl是浏览器内置的不需要引入,但是为了方便矩阵操作等,也有一些比较实用的工具

查看原文

赞 1 收藏 0 评论 0

yuxiaoliang 发布了文章 · 2019-12-02

React Hooks工程实践总结


    最近在项目中基本上全部使用了React Hooks,历史项目也用React Hooks重写了一遍,相比于Class组件,React Hooks的优点可以一句话来概括:就是简单,在React hooks中没有复杂的生命周期,没有类组件中复杂的this指向,没有类似于HOC,render props等复杂的组件复用模式等。本篇文章主要总结一下在React hooks工程实践中的经验。

  • React hooks中的渲染行为
  • React hooks中的性能优化
  • React hooks中的状态管理和通信

原文首发至我的博客: https://github.com/forthealll...


一、React hooks中的渲染行为

1.React hooks组件是如何渲染的

    理解React hooks的关键,就是要明白,hooks组件的每一次渲染都是独立,每一次的render都是一个独立的作用域,拥有自己的props和states、事件处理函数等。概括来讲:

每一次的render都是一个互不相关的函数,拥有完全独立的函数作用域,执行该渲染函数,返回相应的渲染结果

而类组件则不同,类组件中的props和states在整个生命周期中都是指向最新的那次渲染.

React hooks组件和类组件的在渲染行为中的区别,看起来很绕,我们可以用图来区别,

未命名文件 (6)的副本

    上图表示在React hooks组件的渲染过程,从图中可以看出,react hooks组件的每一次渲染都是一个独立的函数,会生成渲染区专属的props和state. 接着来看类组件中的渲染行为:

未命名文件 (6)

    类组件中在渲染开始的时候会在类组件的构造函数中生成一个props和state,所有的渲染过程都是在一个渲染函数中进行的并且,每一次的渲染中都不会去生成新的state和props,而是将值赋值给最开始被初始化的this.props和this.state。

2.工程中注意React hooks的渲染行为

    理解了React hooks的渲染行为,就指示了我们如何在工程中使用。首先因为React hooks组件在每一次渲染的过程中都会生成独立的所用域,因此,在组件内部的子函数和变量等在每次生命的时候都会重新生成,因此我们应该减少在React hooks组件内部声明函数。

写法一:

function App() {
  const [counter, setCounter] = useState(0);
  function formatCounter(counterVal) {
    return `The counter value is ${counterVal}`;
  }
  return (
    <div className="App">
      <div>{formatCounter(counter)}</div>
      <button onClick={() => setCounter(prevState => ++prevState)}>
        Increment
      </button>
    </div>
  );
}

写法二:

function formatCounter(counterVal) {
  return `The counter value is ${counterVal}`;
}
function App() {
 const [counter, setCounter] = useState(0);
 return (
   <div className="App">
     <div>{formatCounter(counter)}</div>
     <button onClick={()=>onClick(setCounter)}>
       Increment
     </button>
   </div>
 );
}

    App组件是一个hooks组件,我们知道了React hooks的渲染行为,那么写法1在每次render的时候都会去重新声明函数formatCounter,因此是不可取的。我们推荐写法二,如果函数与组件内的state和props无相关性,那么可以声明在组件的外部。如果函数与组件内的state和props强相关性,那么我们下节会介绍useCallback和useMemo的方法。

    React hooks中的state和props,在每次渲染的过程中都是重新生成和独立的,那么我们如果需要一个对象,从开始到一次次的render1 , render2, ...中都是不变的应该怎么做呢。(这里的不变是不会重新生成,是引用的地址不变的意思,其值可以改变)

我们可以使用useRef,创建一个“常量”,该常量在组件的渲染期内始终指向同一个引用地址。

通过useRef,可以实现很多功能,比如在某次渲染的时候,拿到前一次渲染中的state。

function App(){
   const [count,setCount] = useState(0)
   const prevCount = usePrevious(count);
   return (
    <div>
      <h1>Now: {count}, before: {prevCount}</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
   );
}
function usePrevious(value) {
   const ref = useRef();
   useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}

上述的例子中,我们通过useRef()创建的ref对象,在整个usePrevious组件的周期内都是同一个对象,我们可以通过更新ref.current的值,来在App组件的渲染过程中,记录App组件渲染中前一次渲染的state.

这里其实还有一个不容易理解的地方,我们来看usePrevious:

function usePrevious(value) {
   const ref = useRef();
   useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}

这里的疑问是:为什么当value改变的时候,返回的ref.current指向的是value改变之前的值?

也就是说:

为什么useEffect在return ref.current之后才执行?

为了解释这个问题,我们来聊聊神奇的useEffect.

3.神奇的useEffect

    hooks组件的每一次渲染都可以看成一个个独立的函数 render1,render2 ... rendern,那么这些render函数之间是怎么关联的呢,还有上小节的问题,为什么在usePrevious中,useEffect在return ref.current之后才执行。带着这两个疑问我们来看看在hooks组件中,最为神奇的useEffect。

    用一句话概括就是:

每一渲染都会生成不同的render函数,并且每一次渲染通过useEffect会生成一个不同的Effects,Effects在每次渲染后声效。

每次渲染除了生成不同的作用域外,如果该hooks组件中使用了useEffect,通过useEffect还会生成一个独有的effects,该effects在渲染完成后生效。

举例来说:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

上述的例子中,完成的逻辑是:

  • 渲染初始的内容:<p>You clicked 0 times</p>
  • 渲染完成之后调用这个effect:{ document.title = 'You clicked 0 times' }。
  • 点击Click me
  • 渲染新的内容渲染的内容: <p>You clicked 1 times</p>
  • 渲染完成之后调用这个effect:() => { document.title = 'You clicked 1 times' }。

也就是说每次渲染render中,effect位于同步执行队列的最后面,在dom更新或者函数返回后在执行。

我们在来看usePrevious的例子:

function usePrevious(value) {
   const ref = useRef();
   useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}

因为useEffect的机制,在新的渲染过程中,先返回ref.current再执行deps依赖更新ref.current,因此usePrevios总是返回上一次的值。

现在我们知道,在一次渲染render中,有自己独立的state,props,还有独立的函数作用域,函数定义,effects等,实际上,在每次render渲染中,几乎所有都是独立的。我们最后来看两个例子:

(1)

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

(2)

function Counter() {
  const [count, setCount] = useState(0);
  
  setTimeout(() => {
      console.log(`You clicked ${count} times`);
  }, 3000);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

这两个例子中,我们在3内点击5次Click me按钮,那么输出的结果都是一样的。

You clicked 0 times
You clicked 1 times
You clicked 2 times
You clicked 3 times
You clicked 4 times
You clicked 5 times

    总而言之,每一次渲染的render,几乎都是独立和独有的,除了useRef创建的对象外,其他对象和函数都没有相关性.

二、React hooks中的性能优化

    前面我们讲了React hooks中的渲染行为,也初步
提到了说将与state和props无关的函数,声明在hooks组件外面可以提高组件的性能,减少每次在渲染中重新声明该无关函数. 除此之外,React hooks还提供了useMemo和useCallback来优化组件的性能.

(1).useCallback

有些时候我们必须要在hooks组件内定义函数或者方法,那么推荐用useCallback缓存这个方法,当useCallback的依赖项不发生变化的时候,该函数在每次渲染的过程中不需要重新声明

useCallback接受两个参数,第一个参数是要缓存的函数,第二个参数是一个数组,表示依赖项,当依赖项改变的时候会去重新声明一个新的函数,否则就返回这个被缓存的函数.

function formatCounter(counterVal) {
  return `The counter value is ${counterVal}`;
}
function App(props) {
 const [counter, setCounter] = useState(0);
 const onClick = useCallback(()=>{
   setCounter(props.count)
 },[props.count]);
 return (
   <div className="App">
     <div>{formatCounter(counter)}</div>
     <button onClick={onClick}>
       Increment
     </button>
   </div>
 );
}

上述例子我们在第一章的例子基础上增加了onClick方法,并缓存了这个方法,只有props中的count改变的时候才需要重新生成这个方法。

(2).useMemo

    useMemo与useCallback大同小异,区别就是useMemo缓存的不是函数,缓存的是对象(可以是jsx虚拟dom对象),同样的当依赖项不变的时候就返回这个被缓存的对象,否则就重新生成一个新的对象。

为了实现组件的性能优化,我们推荐:

在react hooks组件中声明的任何方法,或者任何对象都必须要包裹在useCallback或者useMemo中。

(3)useCallback,useMemo依赖项的比较方法

    我们来看看useCallback,useMemo的依赖项,在更新前后是怎么比较的

import is from 'shared/objectIs';
function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
) {
  if (prevDeps === null) {
    return false;
  }

  
 if (nextDeps.length !== prevDeps.length) {
   return false
 }
 
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++)   {
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

其中is方法的定义为:

function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) 
  );
}

export default (typeof Object.is === 'function' ? Object.is : is);

这个is方法就是es6的Object.is的兼容性写法,也就是说在useCallback和useMemo中的依赖项前后是通过Object.is来比较是否相同的,因此是浅比较。

三、React hooks中的状态管理和通信

  react hooks中的局部状态管理相比于类组件而言更加简介,那么如果我们组件采用react hooks,那么如何解决组件间的通信问题。

(1) UseContext

  最基础的想法可能就是通过useContext来解决组件间的通信问题。

比如:

function useCounter() {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment }
}

let Counter = createContext(null)

function CounterDisplay() {
  let counter = useContext(Counter)
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  )
}

function App() {
  let counter = useCounter()
  return (
    <Counter.Provider value={counter}>
      <CounterDisplay />
      <CounterDisplay />
    </Counter.Provider>
  )
}

  在这个例子中通过createContext和useContext,可以在App的子组件CounterDisplay中使用context,从而实现一定意义上的组件通信。

此外,在useContext的基础上,为了其整体性,业界也有几个比较简单的封装:

https://github.com/jamiebuild...
https://github.com/diegohaz/c...

但是其本质都没有解决一个问题:

如果context太多,那么如何维护这些context

  也就是说在大量组件通信的场景下,用context进行组件通信代码的可读性很差。这个类组件的场景一致,context不是一个新的东西,虽然用了useContext减少了context的使用复杂度。

(2) Redux结合hooks来实现组件间的通信

  hooks组件间的通信,同样可以使用redux来实现。也就是说:

在React hooks中,redux也有其存在的意义

  在hooks中存在一个问题,因为不存在类似于react-redux中connect这个高阶组件,来传递mapState和mapDispatch, 解决的方式是通过redux-react-hook或者react-redux的7.1 hooks版本来使用。

  • redux-react-hook

  在redux-react-hook中提供了StoreContext、useDispatch和useMappedState来操作redux中的store,比如定义mapState和mapDispatch的方式为:

import {StoreContext} from 'redux-react-hook';

ReactDOM.render(
  <StoreContext.Provider value={store}>
    <App />
  </StoreContext.Provider>,
  document.getElementById('root'),
);

import {useDispatch, useMappedState} from 'redux-react-hook';

export function DeleteButton({index}) {
  // Declare your memoized mapState function
  const mapState = useCallback(
    state => ({
      canDelete: state.todos[index].canDelete,
      name: state.todos[index].name,
    }),
    [index],
  );

  // Get data from and subscribe to the store
  const {canDelete, name} = useMappedState(mapState);

  // Create actions
  const dispatch = useDispatch();
  const deleteTodo = useCallback(
    () =>
      dispatch({
        type: 'delete todo',
        index,
      }),
    [index],
  );

  return (
    <button disabled={!canDelete} onClick={deleteTodo}>
      Delete {name}
    </button>
  );
}
  • react-redux 7.1的hooks版

   这也是官方较为推荐的,react-redux 的hooks版本提供了useSelector()、useDispatch()、useStore()这3个主要方法,分别对应与mapState、mapDispatch以及直接拿到redux中store的实例.

简单介绍一下useSelector,在useSelector中除了能从store中拿到state以外,还支持深度比较的功能,如果相应的state前后没有改变,就不会去重新的计算.

举例来说,最基础的用法:

import React from 'react'
import { useSelector } from 'react-redux'

export const TodoListItem = props => {
  const todo = useSelector(state => state.todos[props.id])
  return <div>{todo.text}</div>
}

实现缓存功能的用法:

import React from 'react'
import { useSelector } from 'react-redux'
import { createSelector } from 'reselect'

const selectNumOfDoneTodos = createSelector(
  state => state.todos,
  todos => todos.filter(todo => todo.isDone).length
)

export const DoneTodosCounter = () => {
  const NumOfDoneTodos = useSelector(selectNumOfDoneTodos)
  return <div>{NumOfDoneTodos}</div>
}

export const App = () => {
  return (
    <>
      <span>Number of done todos:</span>
      <DoneTodosCounter />
    </>
  )
}

在上述的缓存用法中,只要todos.filter(todo => todo.isDone).length不改变,就不会去重新计算.

查看原文

赞 4 收藏 1 评论 0

yuxiaoliang 发布了文章 · 2019-09-19

如何优雅的使用react hooks来进行状态管理


  在使用react和redux的过程中,一直有一个问题,哪些状态需要放在redux中,状态需要保存在组件内的local state中,此外不合理的使用redux可能会带来状态管理混乱的问题,此外对于local state局部状态而言,react hooks提供了一个比class中的setState更好的一个替代方案。本文主要从状态管理出发,讲讲如何优雅的使用hooks来进行状态管理。

  • 如何使用redux
  • react hooks管理local state
  • react hooks如何解决组件间的通信

原文在我的博客中:https://github.com/forthealll...
欢迎订阅


一、如何使用redux

  首先要明确为什么要使用redux,这一点很重要,如果不知道为什么使用redux,那么在开发的过程中肯定不能合理的使用redux.首先来看redux的本质:

redux做为一款状态管理工具,主要是为了解决组件间通信的问题。

既然是组件间的通信问题,那么显然将所有页面的状态都放入redux中,是不合理的,复杂度也很高。

(1)全量使用redux

  笔者在早期也犯了这个问题,在应用中,不管什么状态,按页面级路由拆分,全部放在redux中,页面任何状态的更改,通过react-redux的mapState和mapDispatch来实现。

Lark20190919-153334

redux中的状态从状态更新到反馈到视图,是一个过程链太长,从dispatch一个action出发,然后走reducer等逻辑,一个完整的链路包含:

创建action,创建redux中间件,创建相应type的reducer函数,创建mapState和mapDispatch等。

如果将所有状态都保存在redux中,那么每一个状态必须走这几步流程,及其繁琐,毫无疑问增加了代码量

(2)减少局部状态和redux状态的不合理混用

  全量使用redux的复杂度很高,我们当然考虑将一部分状态放在redux中,一部分状态放在local state中,但是这种情况下,很容易产生一个问题,就是如果local State跟redux中的state存在状态依赖。

举例来说,在redux中的状态中有10个学生

    //redux
    
    students = [{name:"小明",score:70},{name:"小红",score:50}....]

在local state中我们保存了分数在60分以上的学生


    // local state
    
    state = [{name:"小明",score:70}]

  如果redux中的学生改变了,我们需要从redux中动态的获取students信息,然后改变局部的state.结合react-redux,我们需要在容器组件中使用componentWillReceivedProps或者getDerivedStateFromProps这个声明周期,来根据props改变局部的local state.

  componentWillReceivedProps这里不讨论,为了更高的安全性,在react中用静态的getDerivedStateFromProps代替了componentWillReceivedProps这里不讨论,而getDerivedStateFromProps这个声明周期函数在props和state变化的时候都会去执行,因此如果我们需要仅仅在props的改变而改变局部的local state,在这个声明周期中会存在着很复杂的判断逻辑。

redux中的状态和local state中的状态相关联的越多,getDerivedStateFromProps这个声明周期函数就越复杂

  给我们的启示就是尽可能的减少getDerivedStateFromProps的使用,如果实在是redux和local state有关联性,用id会比直接用对象或者数组好,比如上述的例子,我们可以将学生分组,并给一个组号,每次在redux中的学生信息发生改变的时候会改变相应的组号。
这样在getDerivedStateFromProps只需要判断组号是否改变即可:


    class Container extends React.Component{
      state = {
         group_id:number
      }
      
      static getDerivedStateFromProps(props,state){
         if(props.group_id!==state.group_id){
         
            ... 更新及格的学生
         }else{
            return null
         }
      }
    }

  这里推荐https://github.com/paularmstr... state关联性强,可以先将数据范式化,范式化后的数据类似于给一个复杂结构一个id,这样子会简化getDerivedStateFromProps的逻辑.

(3)本节小结

  如何使用redux,必须从redux的本质出发,redux的本质是为了解决组件间的通信问题,因此组件内部独有的状态不应该放在redux中,此外如果redux结合class类组件使用,可以将数据范式化,简化复杂的判断逻辑。

二、react hooks管理local state

  前面将了应该如何使用redux,那么如何维护local state呢,React16.8中正式增加了hooks。通过hooks管理local state,简单易用可扩展。

在hooks中的局部状态常见的有3种,分别是useState、useRef和useReducer

(1) useState

useState是hooks中最常见的局部状态,比如:

    const [hide, setHide] = React.useState(false);
    const [name, setName] = React.useState('BI');

理解useState必须明确,在react hooks中:

每一次渲染都有它自己的 Props and State

一个经典的例子就是:

    function Counter() {
      const [count, setCount] = useState(0);
    
      function handleAlertClick() {
        setTimeout(() => {
          alert('You clicked on: ' + count);
        }, 3000);
      }
    
      return (
        <div>
          <p>You clicked {count} times</p>
          <button onClick={() => setCount(count + 1)}>
            Click me
          </button>
          <button onClick={handleAlertClick}>
            Show alert
          </button>
        </div>
      );
    }

如果我按照下面的步骤去操作:

  • 点击增加counter到3
  • 点击一下 “Show alert”
  • 点击增加 counter到5并且在定时器回调触发前完成

猜猜看会alert出什么?
counter-46c55d5f1f749462b7a173f1e748e41e

结果是弹出了3,alert会“捕获”我点击按钮时候的状态,也就是说每一次的渲染都会有独立的props和state.

(2) useRef

  在react hooks中,我们知道了每一次的渲染都会有独立的props和state,那么如果我们需要跟类组件一样,每次都能拿到最新的渲染值时,应该怎么做呢?此时我们可以用useRef

useRef提供了一个Mutable可变的数据

我们来修改上述的例子,来是的alert为5:

    function Counter() {
        const [count, setCount] = useState(0)
        const late = useRef(0)
        function handleAlertClick() {
            setTimeout(() => {
                alert('You clicked on: ' + late.current)
            }, 3000)
        }
        useEffect(() => {
            late.current = count
        })
        return (
            <div>
                <p>You clicked {count} times</p>
                <button onClick={() => setCount(count + 1)}>Click me</button>
                <button onClick={handleAlertClick}>Show alert</button>
            </div>
        )
    }

如此修改以后就不是alert3 而是弹出5

(3) useReducer

  react hooks中也提供了useReducer来管理局部状态.

当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时,你可能需要用useReducer去替换它们。

同样的用例子来说明:

    function Counter() {
      const [state, dispatch] = useReducer(reducer, initialState);
      const { count, step } = state;
    
      useEffect(() => {
        const id = setInterval(() => {
          dispatch({ type: 'tick' });
        }, 1000);
        return () => clearInterval(id);
      }, [dispatch]);
    
      return (
        <>
          <h1>{count}</h1>
          <input value={step} onChange={e => {
            dispatch({
              type: 'step',
              step: Number(e.target.value)
            });
          }} />
        </>
      );
    }
    
    const initialState = {
      count: 0,
      step: 1,
    };
    
    function reducer(state, action) {
      const { count, step } = state;
      if (action.type === 'tick') {
        return { count: count + step, step };
      } else if (action.type === 'step') {
        return { count, step: action.step };
      } else {
        throw new Error();
      }
    }

解释上面的结果主要来看useEffect部分:

    useEffect(() => {
        const id = setInterval(() => {
          dispatch({ type: 'tick' });
        }, 1000);
        return () => clearInterval(id);
      }, [dispatch]);

  在state中的count依赖与step,但是使用了useReducer后,我们不需要在useEffect的依赖变动数组中使用step,转而用dispatch来替代,这样的好处就是减少不必要的渲染行为.

  此外:局部状态不推荐使用 useReducer ,会导致函数内部状态过于复杂,难以阅读。 useReducer 建议在多组件间通信时,结合 useContext 一起使用。

三、react hooks如何解决组件间的通信

  react hooks中的局部状态管理相比于类组件而言更加简介,那么如果我们组件采用react hooks,那么如何解决组件间的通信问题。

(1) UseContext

  最基础的想法可能就是通过useContext来解决组件间的通信问题。

比如:

function useCounter() {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment }
}

let Counter = createContext(null)

function CounterDisplay() {
  let counter = useContext(Counter)
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  )
}

function App() {
  let counter = useCounter()
  return (
    <Counter.Provider value={counter}>
      <CounterDisplay />
      <CounterDisplay />
    </Counter.Provider>
  )
}

  在这个例子中通过createContext和useContext,可以在App的子组件CounterDisplay中使用context,从而实现一定意义上的组件通信。

此外,在useContext的基础上,为了其整体性,业界也有几个比较简单的封装:

https://github.com/jamiebuild...
https://github.com/diegohaz/c...

但是其本质都没有解决一个问题:

如果context太多,那么如何维护这些context

  也就是说在大量组件通信的场景下,用context进行组件通信代码的可读性很差。这个类组件的场景一致,context不是一个新的东西,虽然用了useContext减少了context的使用复杂度。

(2) Redux结合hooks来实现组件间的通信

  hooks组件间的通信,同样可以使用redux来实现。也就是说:

在React hooks中,redux也有其存在的意义

  在hooks中存在一个问题,因为不存在类似于react-redux中connect这个高阶组件,来传递mapState和mapDispatch, 解决的方式是通过redux-react-hook或者react-redux的7.1 hooks版本来使用。

  • redux-react-hook

  在redux-react-hook中提供了StoreContext、useDispatch和useMappedState来操作redux中的store,比如定义mapState和mapDispatch的方式为:

import {StoreContext} from 'redux-react-hook';

ReactDOM.render(
  <StoreContext.Provider value={store}>
    <App />
  </StoreContext.Provider>,
  document.getElementById('root'),
);

import {useDispatch, useMappedState} from 'redux-react-hook';

export function DeleteButton({index}) {
  // Declare your memoized mapState function
  const mapState = useCallback(
    state => ({
      canDelete: state.todos[index].canDelete,
      name: state.todos[index].name,
    }),
    [index],
  );

  // Get data from and subscribe to the store
  const {canDelete, name} = useMappedState(mapState);

  // Create actions
  const dispatch = useDispatch();
  const deleteTodo = useCallback(
    () =>
      dispatch({
        type: 'delete todo',
        index,
      }),
    [index],
  );

  return (
    <button disabled={!canDelete} onClick={deleteTodo}>
      Delete {name}
    </button>
  );
}
  • react-redux 7.1的hooks版

   这也是官方较为推荐的,react-redux 的hooks版本提供了useSelector()、useDispatch()、useStore()这3个主要方法,分别对应与mapState、mapDispatch以及直接拿到redux中store的实例.

简单介绍一下useSelector,在useSelector中除了能从store中拿到state以外,还支持深度比较的功能,如果相应的state前后没有改变,就不会去重新的计算.

举例来说,最基础的用法:

import React from 'react'
import { useSelector } from 'react-redux'

export const TodoListItem = props => {
  const todo = useSelector(state => state.todos[props.id])
  return <div>{todo.text}</div>
}

实现缓存功能的用法:

import React from 'react'
import { useSelector } from 'react-redux'
import { createSelector } from 'reselect'

const selectNumOfDoneTodos = createSelector(
  state => state.todos,
  todos => todos.filter(todo => todo.isDone).length
)

export const DoneTodosCounter = () => {
  const NumOfDoneTodos = useSelector(selectNumOfDoneTodos)
  return <div>{NumOfDoneTodos}</div>
}

export const App = () => {
  return (
    <>
      <span>Number of done todos:</span>
      <DoneTodosCounter />
    </>
  )
}

在上述的缓存用法中,只要todos.filter(todo => todo.isDone).length不改变,就不会去重新计算.

四、总结

   react中完整的状态管理分为全局状态和局部状态,而react hooks简化了局部状态,使得管理局部状态以及控制局部渲染极其方便,但是react hooks本质上还是一个视图组件层的,并没有完美的解决组件间的通信问题,也就是说,redux等状态管理机和react hooks本质上并不矛盾。

  在我的实践中,用redux实现组件间的通信而react hooks来实现局部的状态管理,使得代码简单已读的同时,也减少了很多不必要的redux样板代码.

查看原文

赞 8 收藏 4 评论 0

yuxiaoliang 发布了文章 · 2019-07-04

在Typescript项目中,如何优雅的使用ESLint和Prettier


  对于Typescript项目的编码规范而言,主要有两种选择ESLint和TSLint。ESLint不仅能规范js代码,通过配置解析器,也能规范TS代码。此外由于性能问题,TypeScript 官方决定全面采用ESLint,甚至把仓库作为测试平台,而 ESLint 的 TypeScript 解析器也成为独立项目,专注解决双方兼容性问题。
  最近在我的项目的编码规范中全量的用ESLint代替了TSLint,针对其中遇到的问题做一个记录。

  • 用ESLint来规范Typescript代码
  • 用ESLint来规范React代码
  • 结合Prettier和ESLint来规范代码
  • 在VSCode中使用ESLint
  • husky和lint-staged构建代码工作流
  • gitlab的CI/CD来规范代码

原文在我的博客中: https://github.com/forthealll...

欢迎star和收藏


一、用ESLint来规范Typescript代码

首先安装依赖:

npm i -d eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin

这三个依赖分别是:

  • eslint: ESLint的核心代码
  • @typescript-eslint/parser:ESLint的解析器,用于解析typescript,从而检查和规范Typescript代码
  • @typescript-eslint/eslint-plugin:这是一个ESLint插件,包含了各类定义好的检测Typescript代码的规范

安装好这3个依赖包之后,在根目录下新建.eslintrc.js文件,该文件中定义了ESLint的基础配置,一个最为简单的配置如下所示:

module.exports = {

    parser:  '@typescript-eslint/parser', //定义ESLint的解析器
    extends: ['plugin:@typescript-eslint/recommended'],//定义文件继承的子规范
    plugins: ['@typescript-eslint'],//定义了该eslint文件所依赖的插件
    env:{                          //指定代码的运行环境
        browser: true,
        node: true,
    }                               
}
  • 在ts项目中必须执行解析器为@typescript-eslint/parser,才能正确的检测和规范TS代码
  • env环境变量配置,形如console属性只有在browser环境下才会存在,如果没有设置支持browser,那么可能报console is undefined的错误。

二、用ESLint来规范React代码

如果在你的TS项目中同时使用了React,那么为了检测和规范React代码的书写必须安装插件eslint-plugin-react,然后增加配置:


module.exports = {

    parser:  '@typescript-eslint/parser',
    extends: [
    'plugin:react/recommended'  
    'plugin:@typescript-eslint/recommended'
    ],                              //使用推荐的React代码检测规范
    plugins: ['@typescript-eslint'],
    env:{                         
        browser: true,
        node: true,
    },
    settings: {             //自动发现React的版本,从而进行规范react代码
        "react": {
            "pragma": "React",
            "version": "detect"
        }
    }, 
    parserOptions: {        //指定ESLint可以解析JSX语法
        "ecmaVersion": 2019,
        "sourceType": 'module',
        "ecmaFeatures":{
            jsx:true
        }
    }
    rules: {
    
    }
}

在Rules中可以自定义你的React代码编码规范。

三、结合Prettier和ESLint来规范代码

  Prettier中文的意思是漂亮的、美丽的,是一个流行的代码格式化的工具,我们来看如何结合ESLint来使用。首先我们需要安装三个依赖:

npm i -g prettier eslint-config-prettier eslint-plugin-prettier

其中:

  • prettier:prettier插件的核心代码
  • eslint-config-prettier:解决ESLint中的样式规范和prettier中样式规范的冲突,以prettier的样式规范为准,使ESLint中的样式规范自动失效
  • eslint-plugin-prettier:将prettier作为ESLint规范来使用

然后在项目的根目录下创建.prettierrc.js文件:

module.exports =  {
    "printWidth": 120,
    "semi": false,
    "singleQuote": true,
    "trailingComma": "all",
    "bracketSpacing": false,
    "jsxBracketSameLine": true,
    "arrowParens": "avoid",
    "insertPragma": true,
    "tabWidth": 4,
    "useTabs": false  
  };

接着修改.eslintrc.js文件,引入prettier:

module.exports = {
    parser:  '@typescript-eslint/parser',
    extends:[ 
    'prettier/@typescript-eslint',
    'plugin:prettier/recommended'
    ],
    settings: {
        "react": {
            "pragma": "React",
            "version": "detect"
        }
    },
    parserOptions: {
        "ecmaVersion": 2019,
        "sourceType": 'module',
        "ecmaFeatures":{
            jsx:true
        }
    },
    env:{
        browser: true,
        node: true,
    }  

上述新增的extends的配置中:

  • prettier/@typescript-eslint:使得@typescript-eslint中的样式规范失效,遵循prettier中的样式规范
  • plugin:prettier/recommended:使用prettier中的样式规范,且如果使得ESLint会检测prettier的格式问题,同样将格式问题以error的形式抛出

## 四、在VSCode中集成ESLint配置

  为了开发方便我们可以在VSCode中集成ESLint的配置,使得代码在保存或者代码变动的时候自动进行ESLint的fix过程。

  首先需要安装VSCode的ESLint插件,安装插件完毕后,在settings.json文件中修改其配置文件为:

{
       "eslint.enable": true,  //是否开启vscode的eslint
       "eslint.autoFixOnSave": true, //是否在保存的时候自动fix eslint
       "eslint.options": {    //指定vscode的eslint所处理的文件的后缀
           "extensions": [
               ".js",
               ".vue",
               ".ts",
               ".tsx"
           ]
       },
       "eslint.validate": [     //确定校验准则
           "javascript",
           "javascriptreact",
           {
               "language": "html",
               "autoFix": true
           },
           {
               "language": "vue",
               "autoFix": true
           },
           {
               "language": "typescript",
               "autoFix": true
           },
           {
               "language": "typescriptreact",
               "autoFix": true
           }
       ]
}

主要注意的有两点:

  • eslint.options中可以通过configFile属性来执行eslint规范的绝对路径,默认会向上查找,在根路径中指定。
  • eslint.validate中必须通过{ language: XXX}的形式来指定typescript和typescriptreact

五、husky和lint-staged构建代码工作流

  首先来看husky,Husky 能够帮你阻挡住不好的代码提交和推送,首先我们在package.json中定义如下的script:

 "scripts": {
   "lint": "eslint src --fix --ext .ts,.tsx "
}

接着在package.json定义husky的配置:

 "husky": {
   "hooks": {
      "pre-commit": "npm run lint"
    }
},

我们在git的hook的阶段来执行相应的命令,比如上述的例子是在pre-commit这个hook也就是在提交之前进行lint的检测。

  接着来看lint-staged,上述我们通过在husky的pre-comit这个hook中执行一个npm命令来做lint校验。除了定义个npm lint命令外,我们也可以直接通过使用lint-staged,来在提交前检测代码的规范。
  使用lint-staged来规范代码的方式如下,我们修改package.json文件为:

{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.{.ts,.tsx}": [
      "eslint",
      "git add"
    ]
  }
}

同样在git commit的时候会做lint的检测。

六、gitlab的CI/CD来规范代码

   仅仅通过git的hook来执行代码的规范检测有一个问题,我们可以在git commit的时候通过--no-verify来跳过代码的规范检测。但是某些情况下,我们对于某一个重要分支,该分支上的代码必须完整通过代码的规范检测,此时我们可以使用gitlab的CI/CD。

   同样在package.json中增加一个lint的npm 命令:

  "scripts": {
     "lint": "eslint src --fix --ext .ts,.tsx "
  }

然后在根目录增加.gitlab-ci.yml文件,该文件中的配置为:

stages:
  - lint

before_script:
  - git fetch --all
  - npm install 

lint:
  stage: lint
  script:
    - npm run lint
  only
    - 特定分支1
    - 特定分支2



  然后配置相应的gitlab runner,这里不具体详描,最后的结果就是在我们指定的分支上的提交或者merge都会进行所配置的命令检测。这样保证了特定分支不受git commit跳过操作--no-verify的影响。

查看原文

赞 30 收藏 18 评论 5

yuxiaoliang 发布了文章 · 2019-02-28

你真的懂switch吗?聊聊switch语句中的块级作用域


  最近在代码中不小心不规范的,在switch里面定义了块级变量,导致页面在某些浏览器中出错,本文讨论以下switch语句中的块级作用域。

  • switch语句中的块级作用域
  • switch语句中的块级作用域可能存在的问题
  • 规范和检测

本文的原文在我的博客中,https://github.com/forthealll...

欢迎star

一、switch语句中的块级作用域

  ES6 或 TS 引入了块级作用域,通过let和const、class等可以定义块级作用域里的变量,块级作用域内的变量不存在变量提升,且存在暂时性死区。常见的if语句,for循环的循环体内都可以定义块级变量。那么switch语句中的块级作用域是什么呢? 先给出结论:

switch语句中的块级作用域,在整个switch语句中,而不是对于每一个case生成一个独立的块级作用域。

下面来举几个例子来说明这个问题:

let number = 1;
switch(number){
  case 1:
    let name = 'Jony';
  default:
    console.log(name)
}

上述的代码会输出jony。

再看一个例子:

let number = 1;
switch(number){
  case 1:
    let name = 'Jony';
    break;
  case 2:
    let name = 'yu';
    break;
  default:
   console.log(name);
}

这样会在重复生命的错误:

Uncaught SyntaxError: Identifier 'name' has already been declared

上述两个例子说明确实switch语句中,整个switch语句构成一个块级作用域。而与case无关,每一个case并不会构成一个独立的块级作用域。

二、switch语句中的块级作用域可能存在的问题

  我们知道了switch语句,整个switch语句的顶层是一个块级作用域,但是还要注意case的特殊性,在case中声明的变量,并不会提升到块级作用域中。

let number = 2;
switch(number){
  case 2:
    name = 'yu';
    break;
}

在这个例子中,name虽然没有声明,但是给name赋值相当于给全局的window对象复制,也就是window.name = 'yu'。不会有任何问题。

有意思的问题来了:

let number = 2;
switch(number){
  case 1:
    let name = 'jony';
    break;
  case 2:
    name = 'yu';
    break;
}

这个例子中,会报错,会报name未定义的错误。

Uncaught ReferenceError: name is not defined

原因的话,这里虽然case里面定义的块级虽然不会存在变量提升,但是会存在暂时性死区,也就是说如果let name = 'jony' 没有执行,也就是name定义的过程没有执行,那么name在整个块级作用域内都是不可用的,都是undefined。

为了证明我们的想法,接着改写上面的例子:

let number = 1;
switch(number){
  case 1:
    let name = 'jony';
    break;
  case 2:
    name = 'yu';
    break;
}

我们把number改成1,我们发现代码不会报任何的错误,因为此时let name的定义和赋值都被执行了。

三、规范和检测

一、什么时候会出现问题

  可能会说为什么在自己的项目中,在ES6或者TS代码中即使有上述的错误使用,也没有报错?

  笔者之前也有这样的问题,要明确的是是否你把ES6或者TS的代码直接转化成了es5,然后再调试或者发布的线上的,当let被编译成es5后,当然就不会存在上述switch中作用域的问题。但是现实中,编译成es5后的js文件可能太大,对于高版本浏览器我们希望直接使用ES6代码(通过type = module来判断浏览器对于ES6的支持性),那么这么上述问题就会出现。

二、如何检测和规避

  那么如何避免这种情况呢,当然最好的方式,就是不要在case中定义块级变量,但是万一不小心写了上述的问题代码如何检测呢。

首先使用typescript,静态编译是不能出现错误提示的,因为这个错误是运行时异常。最好的方式是通过编写eslint的规范来解决上述的非法使用问题。

查看原文

赞 2 收藏 0 评论 0

yuxiaoliang 发布了文章 · 2019-02-14

从Express到Nestjs,谈谈Nestjs的设计思想和使用方法

  最近已经使用过一段时间的nestjs,让人写着有一种java spring的感觉,nestjs可以使用express的所有中间件,此外完美的支持typescript,与数据库关系映射typeorm配合使用可以快速的编写一个接口网关。本文会介绍一下作为一款企业级的node框架的特点和优点。

  • 从依赖注入(DI)谈起
  • 装饰器和注解
  • nestjs的“洋葱模型”
  • nestjs的特点总结

原文在我的博客中: https://github.com/forthealll...

欢迎star和fork

一、从依赖注入(DI)谈起

(1)、angular中的依赖注入

  从angular1.x开始,实现了依赖注入或者说控制反转的模式,angular1.x中就有controller(控制器)、service(服务),模块(module)。笔者在早年间写过一段时间的angular1.3,下面举例来说明:

var myapp=angular.module('myapp',['ui.router']);
myapp.controller('test1',function($scope,$timeout){}
myapp.controller('test2',function($scope,$state){}

  上面这个就是angular1.3中的一个依赖注入的例子,首先定义了模块名为“myapp”的module, 接着在myapp这个模块中定义controller控制器。将myapp模块的控制权交给了myapp.controller函数。具体的依赖注入的流程图如下所示:

default

myapp这个模块如何定义,由于它的两个控制器决定,此外在控制器中又依赖于$scope、$timeout等服务。这样就实现了依赖注入,或者说控制反转。

(2)、什么是依赖注入

  用一个例子来通俗的讲讲什么是依赖注入。

class Cat(){

}
class Tiger(){

}
class Zoo(){
  constructor(){
     this.tiger = new Tiger();
     this.cat = new Cat();
  }
}

  上述的例子中,我们定义Zoo,在其constructor的方法中进行对于Cat和Tiger的实例化,此时如果我们要为Zoo增加一个实例变量,比如去修改Zoo类本身,比如我们现在想为Zoo类增加一个Fish类的实例变量:

class Fish(){}

class Zoo(){
  constructor(){
     this.tiger = new Tiger();
     this.cat = new Cat();
     this.fish = new Fish();
  }
}

  此外如果我们要修改在Zoo中实例化时,传入Tiger和Cat类的变量,也必须在Zoo类上修改。这种反反复复的修改会使得Zoo类并没有通用性,使得Zoo类的功能需要反复测试。

我们设想将实例化的过程以参数的形式传递给Zoo类:

class Zoo(){
  constructor(options){
     this.options = options;
  }
}
var zoo = new Zoo({
  tiger: new Tiger(),
  cat: new Cat(),
  fish: new Fish()
})

  我们将实力化的过程放入参数中,传入给Zoo的构造函数,这样我们就不用在Zoo类中反复的去修改代码。这是一个简单的介绍依赖注入的例子,更为完全使用依赖注入的可以为Zoo类增加静态方法和静态属性:

class Zoo(){
  static animals = [];
  constructor(options){
     this.options = options;
     this.init();
  }
  init(){
    let _this = this;
    animals.forEach(function(item){
      item.call(_this,options);
    })
  }
  static use(module){
     animals.push([...module])
  }
}
Zoo.use[Cat,Tiger,Fish];
var zoo = new Zoo(options);

  上述我们用Zoo的静态方法use往Zoo类中注入Cat、Tiger、Fish模块,将Zoo的具体实现移交给了Cat和Tiger和Fish模块,以及构造函数中传入的options参数。

(3)、nestjs中的依赖注入

  在nestjs中也参考了angular中的依赖注入的思想,也是用module、controller和service。

@Module({
  imports:[otherModule],
  providers:[SaveService],
  controllers:[SaveController,SaveExtroController]
})
export class SaveModule {}

  上面就是nestjs中如何定一个module,在imports属性中可以注入其他模块,在prividers注入相应的在控制器中需要用到的service,在控制器中注入需要的controller。

二、装饰器和注解

  在nestjs中,完美的拥抱了typescript,特别是大量的使用装饰器和注解,对于装饰器和注解的理解可以参考我的这篇文章:Typescript中的装饰器和注解。我们来看使用了装饰器和注解后,在nestjs中编写业务代码有多么的简洁:

import { Controller, Get, Req, Res } from '@nestjs/common';

@Controller('cats')

export class CatsController {
  @Get()
  findAll(@Req() req,@Res() res) {
    return 'This action returns all cats';
  }
}

  上述定义两个一个处理url为“/cats”的控制器,对于这个路由的get方法,定义了findAll函数。当以get方法,请求/cats的时候,就会主动的触发findAll函数。

  此外在findAll函数中,通过req和res参数,在主题内也可以直接使用请求request以及对于请求的响应response。比如我们通过req上来获取请求的参数,以及通过res.send来返回请求结果。

三、nestjs的“洋葱模型”

  这里简单讲讲在nestjs中是如何分层的,也就是说请求到达服务端后如何层层处理,直到响应请求并将结果返回客户端。

在nestjs中在service的基础上,按处理的层次补充了中间件(middleware)、异常处理(Exception filters)、管道(Pipes),守卫(Guards),以及拦截器(interceptors)在请求到打真正的处理函数之间进行了层层的处理。

default

上图中的逻辑就是分层处理的过程,经过分层的处理请求才能到达服务端处理函数,下面我们来介绍nestjs中的层层模型的具体作用。

(1)、middleware中间件

  在nestjs中的middle完全跟express的中间件一摸一样。不仅如此,我们还可以直接使用express中的中间件,比如在我的应用中需要处理core跨域:

import * as cors from 'cors';
async function bootstrap() {
  onst app = await NestFactory.create(/* 创建app的业务逻辑*/)
  app.use(cors({
    origin:'http://localhost:8080',
    credentials:true
  }));
  await app.listen(3000)
}
bootstrap();

在上述的代码中我们可以直接通过app.use来使用core这个express中的中间件。从而使得server端支持core跨域等。

初此之外,跟nestjs的中间件也完全保留了express中的中间件的特点:

  • 在中间件中接受response和request作为参数,并且可以修改请求对象request和结果返回对象response
  • 可以结束对于请求的处理,直接将请求的结果返回,也就是说可以在中间件中直接res.send等。
  • 在该中间件处理完毕后,如果没有将请求结果返回,那么可以通过next方法,将中间件传递给下一个中间件处理。

在nestjs中,中间件跟express中完全一样,除了可以复用express中间件外,在nestjs中针对某一个特定的路由来使用中间件也十分的方便:

class ApplicationModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}

上面就是对于特定的路由url为/cats的时候,使用LoggerMiddleware中间件。

(2)、Exception filters异常过滤器

  Exception filters异常过滤器可以捕获在后端接受处理任何阶段所跑出的异常,捕获到异常后,然后返回处理过的异常结果给客户端(比如返回错误码,错误提示信息等等)。

  我们可以自定义一个异常过滤器,并且在这个异常过滤器中可以指定需要捕获哪些异常,并且对于这些异常应该返回什么结果等,举例一个自定义过滤器用于捕获HttpException异常的例子。

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

  我们可以看到host是实现了ArgumentsHost接口的,在host中可以获取运行环境中的信息,如果在http请求中那么可以获取request和response,如果在socket中也可以获取client和data信息。

  同样的,对于异常过滤器,我们可以指定在某一个模块中使用,或者指定其在全局使用等。

(3)Pipes管道

  Pipes一般用户验证请求中参数是否符合要求,起到一个校验参数的功能。

  比如我们对于一个请求中的某些参数,需要校验或者转化参数的类型:

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}

  上述的ParseIntPipe就可以把参数转化成十进制的整型数字。我们可以这样使用:


@Get(':id')
async findOne(@Param('id', new ParseIntPipe()) id) {
  return await this.catsService.findOne(id);
}

  对于get请求中的参数id,调用new ParseIntPipe方法来将id参数转化成十进制的整数。

(4)Guards守卫

  Guards守卫,其作用就是决定一个请求是否应该被处理函数接受并处理,当然我们也可以在middleware中间件中来做请求的接受与否的处理,与middleware相比,Guards可以获得更加详细的关于请求的执行上下文信息。

通常Guards守卫层,位于middleware之后,请求正式被处理函数处理之前。

下面是一个Guards的例子:

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}

这里的context实现了一个ExecutionContext接口,该接口中具有丰富的执行上下文信息。


export interface ArgumentsHost {
  getArgs<T extends Array<any> = any[]>(): T;
  getArgByIndex<T = any>(index: number): T;
  switchToRpc(): RpcArgumentsHost;
  switchToHttp(): HttpArgumentsHost;
  switchToWs(): WsArgumentsHost;
}

export interface ExecutionContext extends ArgumentsHost {
  getClass<T = any>(): Type<T>;
  getHandler(): Function;
}

除了ArgumentsHost中的信息外,ExecutionContext还包含了getClass用户获取对于某一个路由处理的,控制器。而getClass用于获取返回对于指定路由后台处理时的处理函数。

对于Guards处理函数,如果返回true,那么请求会被正常的处理,如果返回false那么请求会抛出异常。

(5)、interceptors拦截器

   拦截器可以给每一个需要执行的函数绑定,拦截器将在该函数执行前或者执行后运行。可以转换函数执行后返回的结果等。

概括来说:

interceptors拦截器在函数执行前或者执行后可以运行,如果在执行后运行,可以拦截函数执行的返回结果,修改参数等。

再来举一个超时处理的例子:

@Injectable()
export class TimeoutInterceptor implements NestInterceptor{
  intercept(
    context:ExecutionContext,
    call$:Observable<any>
  ):Observable<any>{
    return call$.pipe(timeout(5000));
  }
}

该拦截器可以定义在控制器上,可以处理超时请求。

四、nestjs的特点总结

  最后总结一下nestjs的优缺。

nestjs的优点:

  • 完美的支持typescript,因此可以使用日益繁荣的ts生态工具
  • 兼容express中间件,因为express是最早出现的轻量级的node server端框架,nestjs能够利用所有express的中间件,使其生态完善
  • 层层处理,一定程度上可以约束代码,比如何时使用中间件、何时需要使用guards守卫等。
  • 依赖注入以及模块化的思想,提供了完整的mvc的链路,使得代码结构清晰,便于维护,这里的m是数据层可以通过modules的形式注入,比如通过typeorm的entity就可以在模块中注入modules。
  • 完美支持rxjs
查看原文

赞 7 收藏 5 评论 1

yuxiaoliang 发布了文章 · 2019-01-24

React生态,dva源码阅读


  dva的思想还是很不错的,大大提升了开发效率,dva集成了Redux以及Redux的中间件Redux-saga,以及React-router等等。得益于Redux的状态管理,以及Redux-saga中通过Task和Effect来处理异步的概念,dva在这些工具的基础上高度封装,只暴露出几个简单的API就可以设计数据模型。

  最近看了一下Redux-saga的源码,结合以及之前在项目中一直采用的是redux-dark模式来将reducers和sagas(generator函数处理异步)拆分到不同的子页面,每一个页面中同一个文件中包含了该页面状态的reducer和saga,这种简单的封装已经可以大大的提升项目的可读性。

  最近看了dva源码,熟悉了dva是在上层如何做封装的。下面会从浅到深,淡淡在阅读dva源码过程中自己的理解。

  • redux-dark模式
  • dva 0.0.12版本的使用和源码理解

本文的原文地址为: https://github.com/forthealll...
欢迎star


一、redux-dark模式

  在使用redux和redux-saga的时候,特别是如何存放reducer函数和saga的generator函数,这两个函数是直接跟如何处理数据挂钩的。

  回顾一下,在redux中使用异步中间件redux-saga后,完整的数据和信息流向:

default

  在存在异步的逻辑下,在UI Component中发出一个plain object的action,然后经过redux-saga这个中间件处理,redux-saga会将这个action传入相应channel,通过redux-saga的effect方法(比如call、put、apply等方法)生成一个描述对象,然后将这个描述对象转化成具有副作用的函数并执行。

  在redux-saga执行具有副作用的函数时,又可以dispatch一个action,这个action也是一个plain object,会直接传入到redux的reducer函数中进行处理,也就是说在redux-saga的task中发出的action,就是同步的action。

简单的概括:从UI组件上发出的action经过了2层的处理,分别是redux-saga中间件和redux的reducer函数。

  redux-dark模式很简单,就是将同一个子页面下的redux-saga处理action的saga函数,以及reducer处理该子页面下的state的reducer函数,放在同一个文件中。

举例来说:

 import { connect } from 'react-redux';
 class Hello extends React.Component{
     componentDidMount(){
       //发出一个action,获取异步数据
       this.props.dispatch({
          type:'async_count'
       })
     }
     
 
 }
 export default connect({})(Hello);
 

从Hello组件中发出一个type = 'async_count'的action,我们用redux-dark模式来将saga和reducer函数放在同一个文件中:

    import { takeEvery } from 'redux-saga/effects';
    
    //saga
    function * asyncCount(){
      console.log('执行了saga异步...')
      //发出一个原始的action
      yield put({
        type:'async'
      });
    }
    function * helloSaga(){
        //接受从UI组件发出的action
        takeEvery('async_count',asyncCount);
    }
    
    //reducer
    function helloReducer(state,action){
       if(action.type === 'count');
       return { ...state,count + 1}
    }

上述就是一个将saga和reducer放在同一个文件里面的例子。redux-dark模式来组织代码,可以显得比较直观,统一了数据的处理层。分拆子页面后,每一个子页面对应一个文件。可读性很高。

二、dva 0.0.12版本的使用和源码理解

  上述的redux-dark模式,就是一种简单的处理,而dva的话是做了更近一步的封装,dva不仅封装了redux和redux-saga,还有react-router-redux、react-router等等。使得我们可以通过很简单的配置,就能使用redux、redux-saga、react-router等。

下面首先以dva的初始版本为例来理解一下dva的源码。

(1)、dva 0.0.12的使用

来看官网给的使用dva 0.0.12的例子:

// 1. Initialize
const app = dva();

// 2. Model
app.model({
  namespace: 'count',
  state: 0,
  effects: {
    ['count/add']: function*() {
      console.log('count/add');
      yield call(delay, 1000);
      yield put({
        type: 'count/minus',
      });
    },
  },
  reducers: {
    ['count/add'  ](count) { return count + 1 },
    ['count/minus'](count) { return count - 1 },
  },
  subscriptions: [
    function(dispatch) {
      //..处理监听等等函数
    }
  ],
  
});

// 3. View
const App = connect(({ count }) => ({
  count
}))(function(props) {
  return (
    <div>
      <h2>{ props.count }</h2>
      <button key="add" onClick={() => { props.dispatch({type: 'count/add'})}}>+</button>
      <button key="minus" onClick={() => { props.dispatch({type: 'count/minus'})}}>-</button>
    </div>
  );
});

// 4. Router
app.router(({ history }) =>
  <Router history={history}>
    <Route path="/" component={App} />
  </Router>
);

// 5. Start
app.start(document.getElementById('root'));

只要三步就完成了一个例子,如何处理action呢,我们以一个图来表示:

default

也就是做UI组件上发出的对象类型的action,先去根据类型匹配=model初始化时候,effects属性中的action type。

  • 如果在effects的属性中有相应的action type的处理函数,那么先执行effects中的相应函数,在执行这个函数里面可以二次发出action,二次发出的action会直接传入到reducer函数中。
  • 如果effects的属性中没有相应的action type的处理函数,那么会直接从reducer中寻找有没有相应类型的处理函数。

在dva初始化过程中的effects属性中的函数,其实就是redux-saga中的saga函数,在该函数中处理直接的异步逻辑,并且该函数可以二次发出同步的action。

此外dva还可以通过router方法初始化路由等。

(2)、dva 0.0.12的源码阅读

下面来直接读读dva 0.0.12的源码,下面的代码是经过我精简后的dva的源码:

//Provider全局注入store
import { Provider } from 'react-redux';
//redux相关的api
import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
//redux-saga相关的api,takeEvery和takeLatest监听等等
import createSagaMiddleware, { takeEvery, takeLatest } from 'redux-saga';
//react-router相关的api
import { hashHistory, Router } from 'react-router';
//在react-router4.0之后已经较少使用,将路由的状态存储在store中
import { routerMiddleware, syncHistoryWithStore, routerReducer as routing } from 'react-router-redux';
//redux-actions的api,可以以函数式描述reducer等
import { handleActions } from 'redux-actions';
//redux-saga非阻塞调用effect
import { fork } from 'redux-saga/effects';

function dva() {
  let _routes = null;
  const _models = [];
  //new dva暴露了3个方法
  const app = {
    model,
    router,
    start,
  };
  return app;
  //添加models,一个model对象包含了effects,reducers,subscriptions监听器等等
  function model(model) {
    _models.push(model);
  }
  //添加路由
  function router(routes) {
    _routes = routes;
  }

  
  function start(container) {

    let sagas = {};
    //routing是react-router-redux的routerReducer别名,用于扩展reducer,这样以后扩展后的reducer就可以处理路由变化。
    let reducers = {
      routing
    };
    _models.forEach(model => {
      //对于每一个model,提取其中的reducers和effects,其中reducers用于扩展redux的reducers函数,而effects用于扩展redx-saga的saga处理函数。
      reducers[model.namespace] = handleActions(model.reducers || {}, model.state);
      //扩展saga处理函数,sagas是包含了所有的saga处理函数的对象
      sagas = { ...sagas, ...model.effects }; ---------------------------(1)
    });

    reducers = { ...reducers };
    
    //获取决定使用React-router中的那一个api
    const _history = opts.history || hashHistory;
    //初始化redux-saga
    const sagaMiddleware = createSagaMiddleware();
    //为redux添加中间件,这里添加了处理路由的中间件,以及redux-saga中间件。
    const enhancer = compose(
      applyMiddleware.apply(null, [ routerMiddleware(_history), sagaMiddleware ]),
      window.devToolsExtension ? window.devToolsExtension() : f => f
    );
    const initialState = opts.initialState || {};
    //通过combineReducers来扩展reducers,同时生成扩展后的store实例
    const store = app.store = createStore(
      combineReducers(reducers), initialState, enhancer
    );

    // 执行model中的监听函数,监听函数中传入store.dispatch
    _models.forEach(({ subscriptions }) => {
      if (subscriptions) {
        subscriptions.forEach(sub => {
         store.dispatch, onErrorWrapper);
        });
      }
    });
    
     // 根据rootSaga来启动saga,rootSaga就是redux-saga运行的主task
    sagaMiddleware.run(rootSaga);
    
    
    //创建history实例子,可以监听store中的state的变化。
    let history;
    history = syncHistoryWithStore(_history, store); --------------------------------(2)
    

    // Render and hmr.
    if (container) {
      render();
      apply('onHmr')(render);
    } else {
      const Routes = _routes;
      return () => (
        <Provider store={store}>
          <Routes history={history} />
        </Provider>
      );
    }

    function getWatcher(k, saga) {
      let _saga = saga;
      let _type = 'takeEvery';
      if (Array.isArray(saga)) {
        [ _saga, opts ] = saga;
    
        _type = opts.type;
      }

      function* sagaWithErrorCatch(...arg) {
        try {
          yield _saga(...arg);
        } catch (e) {
          onError(e);
        }
      }

      if (_type === 'watcher') {
        return sagaWithErrorCatch;
      } else if (_type === 'takeEvery') {
        return function*() {
          yield takeEvery(k, sagaWithErrorCatch);
        };
      } else {
        return function*() {
          yield takeLatest(k, sagaWithErrorCatch);
        };
      }
    }

    function* rootSaga() {
      for (let k in sagas) {
        if (sagas.hasOwnProperty(k)) {
          const watcher = getWatcher(k, sagas[k]);
          yield fork(watcher);
        }                      -----------------------------(3)
      }
    }

    function render(routes) {
      const Routes = routes || _routes;
      ReactDOM.render((
        <Provider store={store}>
          <Routes history={history} />
        </Provider>
      ), container);
    }
  }
}

export default dva;

代码的阅读在上面都以注视的方式给出,值得注意的主要有一下3点:

  • 在注释(1)处, handleActions是通过redux-actions封装后的一个API,用于简化reducer函数的书写。下面是一个handleActions的例子:
const reducer = handleActions(
  {
    INCREMENT: (state, action) => ({
      counter: state.counter + action.payload
    }),
​
    DECREMENT: (state, action) => ({
      counter: state.counter - action.payload
    })
  },
  { counter: 0 }
);

INCREMENT和DECREMENT属性的函数就可以分别处理,type = "INCREMENT"和type = "DECREMENT"的action。

  • 在注释 (2) 处,通过react-router-redux的api,syncHistoryWithStore可以扩展history,使得history可以监听到store的变化。
  • 在注释(3)处是一个rootSaga, 是redux-saga运行的时候的主Task,在这个Task中我们这样定义:
function* rootSaga() {
  for (let k in sagas) {
    if (sagas.hasOwnProperty(k)) {
      const watcher = getWatcher(k, sagas[k]);
      yield fork(watcher);
    }                     
  }
}

从全局的包含所有saga函数的sagas对象中,获取相应的属性,并fork相应的监听,这里的监听常用的有takeEvery和takeLatest等两个redux-saga的API等。

总结:上面就是dva最早版本的源码,很简洁的使用了redux、redux-saga、react-router、redux-actions、react-router-redux等.其目的也很简单:

简化redux相关生态的繁琐逻辑

参考源码地址:https://github.com/dvajs/dva/...

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 799 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-07-10
个人主页被 1.7k 人浏览