前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >用Async解决回调问题

用Async解决回调问题

作者头像
前端达人
发布2018-10-18 14:52:49
1.2K0
发布2018-10-18 14:52:49
举报
文章被收录于专栏:前端达人前端达人

概述

第一次接触编程时,我们就知道了一块代码是从头执行到尾的。 这就是所谓的同步编程:每个操作完成之后,后面的才会继续。 对于不花计算机太多时间的操作,比如数字相加、操作字符串、或变量赋值等等,这种执行过程没什么问题。

但如果一个任务花的时间稍微长一点,你该怎么办呢?比如访问磁盘上的一个文件,发送一个网络请求,或等待一个计时器结束。 在同步编程中,这时候你的程序啥也做不了,只能干等着。

对于一些简单的情况,你的程序可以有多个实例同时在跑,或许还能忍受,但是对于很多服务器应用来说,这就是个噩梦。

进入异步编程 在异步执行的程序中,你的代码在等待某件事的同时可以继续执行,然后这件事发生了你又可以跳回去。

以网络请求为例。 向一个较慢的服务器发送一个网络请求,可能足足要花三秒钟才能响应,你的程序可以在这个慢服务器响应的同时继续干其它的事。 在这个例子中,三秒钟对人来说或许算不了什么,但服务器不一样,它可能还等着响应上千个其它请求呢。 那么,你要如何在Node.js中处理异步呢?

最基本的方式是使用回调。 一个回调其实就是一个函数,只不过它是在一个异步操作完成时被调用。 按惯例,Node.js的回调函数至少应该有一个参数,err。 回调可以有更多的参数 (通常表示传递给回调函数的数据),但至少应该有一个是err。 你可能已经猜到了,err表示一个错误对象 (当发生了一个错误时就会产生这样一个对象,后面还会提到)

我们来看一个非常简单的例子。 我们要用到Node.js内置的文件系统模块fs。 在此脚本中,我们会去读一个文本文件的内容。 此代码的最后一行是一个console.log,那么问题来了:如果你执行这个脚本,你会在看到文件内容之前看到这个日志结果吗?

代码语言:javascript
复制
var fs  = require('fs');
fs.readFile(
    'a-text-file.txt',      //the filename of a text file that says "Hello!"
    'utf8',                 //the encoding of the file, in this case, utf-8
    function(err,text) {    //the callback
        console.log('Error:',err);    //Errors, if any
        console.log('Text:',text);    //the contents of the file
    }
);
//Will this be before or after the Error / Text?
console.log('Does this get logged before or after the contents of the text file?');

因为它是异步的,我们实际上会在看到文本内容之前就看到最后一句console.log的执行了。 如果你在该脚本的同一目录下有一个名为a-text-file.txt的文件,你会看到err值为null,而text的值为此文本的内容。

如果不存在a-text-file.txt文件,err为一个Error对象,而text的值是undefined。 这种情况产生了一类重要的回调:因为错误无处不在,你总是要处理它们,回调就是一种重要方式。 为处理错误,你需要检查err变量的值,如果它有非nul值,则说明有错误发生了。 一般来说,err参数不会是false,所以总可通过真值检测来判断是否有错。

代码语言:javascript
复制
var fs  = require('fs');
fs.readFile(
    'a-text-file.txt',      //the filename of a text file that says "Hello!"
    'utf8',                 //the encoding of the file, in this case, utf-8
    function(err,text) {    //the callback
        if (err) {
            console.error(err);           //display an error to the console
        } else {
            console.log('Text:',text);    //no error, so display the contents of the file
        }
    }
);

又比如说你想按照一定的顺序展示两个文件的内容。 你会得到类似于这样的代码:

代码语言:javascript
复制
var fs  = require('fs');
fs.readFile(
    'a-text-file.txt',      //the filename of a text file that says "Hello!"
    'utf8',                 //the encoding of the file, in this case, utf-8
    function(err,text) {    //the callback
        if (err) {
            console.error(err);           //display an error to the console
        } else {
            console.log('First text file:',text);    //no error, so display the contents of the file
            fs.readFile(
                'another-text-file.txt',  //the filename of a text file that says "Hello!"
                'utf8',                   //the encoding of the file, in this case, utf-8
                function(err,text) {      //the callback
                    if (err) {
                        console.error(err);                       //display an error to the console
                    } else {
                        console.log('Second text file:',text);    //no error, so display the contents of the file
                    }
                }
            );
        }
    }
); 

这个代码不仅看起来太丑,且存在不少问题:

你是在串行加载文件;如果同时加载并在都加载完时返回,效率会更高。

语法上正确,可读性却极差。 注意那嵌套函数的数目,和不断深入的缩进,想想就可怕。 你可以用一些技巧让它看起来更好一些,但又会牺牲一些其他方面的可读性。

这种写法不是通用方式。 对于两个文件或许可行,但如果有9个文件呢?22个文件呢?1个呢? 当前这种写法就太不灵活了。

但别急,我们可以用async.js来解决所有这些问题 (也许还能解决其他一些问题呢)。

用Async.js进行回调

首先,让我们从安装async.js入手。

npm install async —-save

Async.js可将一系列函数粘连起来,既可以是串行,也可以是并行。 让我们重写前面的例子吧:

代码语言:javascript
复制
var async = require('async'),     //async.js module
    fs    = require('fs');
async.series(                     //execute the functions in the first argument one after another
    [                               //The first argument is an array of functions
        function(cb) {                //`cb` is shorthand for "callback"
            fs.readFile(
                'a-text-file.txt',
                'utf8',
                cb
            );
        },
        function(cb) {
            fs.readFile(
                'another-text-file.txt',
                'utf8',
                cb
            );
        }
    ],

    function(err,values) {          //The "done" callback that is ran after the functions in the array have completed
        if (err) {                    //If any errors occurred when functions in the array executed, they will be sent as the err.
            console.error(err);
        } else {                      //If err is falsy then everything is good
            console.log('First text file:',values[0]);
            console.log('Second text file:',values[1]);
        }
    }
);

两个代码几乎是一样的,串行加载每个文件,唯一的区别在于这里在读完所有文件之后才显示结果。 相比而言,这个代码更简洁清晰 (后面还会有其他改进)。 async.series取一个函数数组作为参数,并串行执行它们。

每个函数只能有一个参数,即回调 (在我们的代码中是cb)。 cb执行时应该与其他任意回调一样具有相同类型的参数,所以我们将其传入为fs.readFile的参数。

最后,它们的结果被发送到最后的回调,即async.series的第二个参数。 这些结果被存在一个数组中,它们按async.series第一个参数中的函数的顺序而排列。

通过async.js,错误处理被简化了,因为如果遇到一个错误,它会返回错误到最后一个回调中,并且不在执行任何其他异步函数。

所有内容合到一起

另一个相关的函数是async.parallel;它和async.series的参数相同,所以你总可以不改变其他语法的情况下替换使用这两个函数。 这里,很适合于讲一下并行和并发的异同。

JavaScript基本上算是一种单线程的语言,即它一次只能同时做一件事。 但它可以在一个独立的线程中处理其他任务 (比如大部分I/O函数),这也正是异步编程在JS中发力之处。 但不要把并行和并发弄混了。

当你用async.parallel执行两件事时,你并没有打开另一个线程去解析JavaScript,也没有同时做两件事----你只不过在async.parallel的第一个参数中的函数间传递控制权。 所以,如果你将同步代码塞到async.parallel中,并没有任何好处。

我们最好用图示来解释:

这就是前面我们用并行方式重写的例子----唯一的差别在于用async.parallel取代了async.series。

代码语言:javascript
复制
var async = require('async'),       //async.js module
    fs    = require('fs');
async.parallel(                   //execute the functions in the first argument, but don't wait for the first function to finish to start the second
    [                               //The first argument is an array of functions
        function(cb) {                //`cb` is shorthand for "callback"
            fs.readFile(
                'a-text-file.txt',
                'utf8',
                cb
            );
        },
        function(cb) {
            fs.readFile(
                'another-text-file.txt',
                'utf8',
                cb
            );
        }
    ],
    function(err,values) {          //The "done" callback that is ran after the functions in the array have completed
        if (err) {                    //If any errors occurred when functions in the array executed, they will be sent as the err.
            console.error(err);
        } else {                      //If err is falsy then everything is good
            console.log('First text file:',values[0]);
            console.log('Second text file:',values[1]);
        }
    }
);

执行一遍又一遍

在前面的例子中,我们执行的是固定数目的操作,但如果是变化个数的异步操作呢? 如果你只用回调和常规语言构造,代码会迅速变得一团糟,因为你需要用一些拙劣的计数器或条件检测,这会掩盖代码的真正逻辑。 让我们看一个用async.js重写的循环代码吧。

在这个例子中,我们要在当前目录中写入十个文件,文件名由计数确定,每个文件中包含了简短的内容。 通过修改async.times的第一个参数,你可以改变文件的数目。 本例中,fs.writeFile的回调只需要一个err参数,而async.times函数还可以支持一个返回值。 和async.series一样,它被存到一个数组中,传递给最后一个回调的第二个参数。

代码语言:javascript
复制
var async = require('async'),
    fs    = require('fs');
async.times(
    10,                                   // number of times to run the function
    function(runCount,callback) {
        fs.writeFile(
            'file-'+runCount+'.txt',          //the new file name
            'This is file number '+runCount,  //the contents of the new file
            callback
        );
    },
    function(err) {
        if (err) {
            console.error(err);
        } else {
            console.log('Wrote files.');
        }
    }
);

这里有必要提一下的是,async.js中的大部分函数都默认是并行而非串行执行的。 所以,在上述例子中,它会同时开始生成文件,并在最后完全写完之时汇报结果。

这些默认并行执行的函数都有一个相对应的串行函数,函数命名方式大概你也猜到了,后缀为'Series’。 所以,如果你想以串行而非并行执行上述例子,只需要将async.times换成async.timesSeries即可。

在我们下一个循环的例子中,我们要介绍async.unti函数。 async.until会一直 (串行) 执行一个异步函数,直到指定条件满足为止。 这个函数有三个函数参数。

第一个函数参数是一个测试,如果你希望终止循环,就让它返回真值,如果你希望循环一直继续下去,那就让它返回假值。 第二个函数参数是一个异步函数,最后一个函数参数是一个完成回调函数。 看一看下面这个例子:

代码语言:javascript
复制
var async     = require('async'), 
fs        = require('fs'),
startTime = new Date().getTime(),   //the unix timestamp in milliseconds
runCount  = 0;
async.until(
    function () {
        //return true if 4 milliseconds have elapsed, otherwise false (and continue running the script)
        return new Date().getTime() > (startTime + 5);
    },
    function(callback) {
        runCount += 1;
        fs.writeFile(
            'timed-file-'+runCount+'.txt',    //the new file name
            'This is file number '+runCount,  //the contents of the new file
            callback
        );
    },
    function(err) {
        if (err) {
            console.error(err);
        } else {
            console.log('Wrote files.');
        }
    }
);

这个脚本花费5毫秒来生成新的文件。 在脚本开始,我们记录了开始的时间 (unix纪元时间),然后在测试函数中我们得到当前时间,并将其与开始时间比较,看是否超过了5毫秒。 如果你多次执行这个脚本,你会得到不同的结果。

在我的机器上,5毫秒可生成6到20个文件。 有意思的是,如果你尝试在测试函数或异步函数中加入console.log,你会得到不同的结果,因为写到终端也是需要时间的。 这只不过是告诉你,在软件中,一切都是有性能开销的。

for each循环是一个好用的结构,它可以让你通过访问数组的每一项来分别完成一些事情。 在async.js中,实现这个功能的是async.each函数。 此函数有三个参数:集合或数组,操作每一项的异步函数,完成回调。

在下面的示例中,我们取一个字符串数组 (这里是狩猎犬品种),并为每个字符串生成一个文件。 当所有文件都生成完毕时,完成回调会被执行。 你大概猜到了,错误是通过err对象传递到完成回调中去的。 async.each是并行执行的,但如果你想要串行执行,你需要将async.each换成async.eachSeries。

代码语言:javascript
复制
var async     = require('async'),
    fs        = require('fs');
async.each(
    //an array of sighthound dog breeds
    ['greyhound','saluki','borzoi','galga','podenco','whippet','lurcher','italian-greyhound'],
    function(dogBreed, callback) {
        fs.writeFile(
            dogBreed+'.txt',                         //the new file name
            'file for dogs of the breed '+dogBreed,  //the contents of the new file
            callback
        );
    },
    function(err) {
        if (err) {
            console.error(err);
        } else {
            console.log('Done writing files about dogs.');
        }
    }
);

async.each的一表亲是async.map函数;它们的差别在于你可以将值传回到完成回调中去。 使用async.map函数时,你将一个数组或一个集合作为每一个参数传入,然后传入一个异步函数,作用于数组或集合的每个元素。 最后一个函数是完成回调。

下面的例子中,传入了狗的品种数组,并用每一项生成一个文件名。 然后,文件名被传入到fs.readFile中,它会将文件内容读出来,并传递回回调函数。 最后,你会在完成回调函数中接收到一个文件内容的数组。

代码语言:javascript
复制
var async     = require('async'),
    fs        = require('fs');
async.map(
    ['greyhound','saluki','borzoi','galga','podenco','whippet','lurcher','italian-greyhound'],
    function(dogBreed, callback) {
        fs.readFile(
            dogBreed+'.txt',    //the new file name
            'utf8',
            callback
        );
    },
    function(err, dogBreedFileContents) {
        if (err) {
            console.error(err);
        } else {
            console.log('dog breeds');
            console.log(dogBreedFileContents);
        }
    }
);

async.filter与async.each及async.map在语法上也很像,只不过它传递一个布尔值给每一项的回调,而非文件的值。 在完成回调中,你得到一个新数组,但它只包含那些你在每项回调中传入一个true或真值对应的些项的文件内容。

代码语言:javascript
复制
var async     = require('async'),
    fs        = require('fs');
async.filter(
    ['greyhound','saluki','borzoi','galga','podenco','whippet','lurcher','italian-greyhound'],
    function(dogBreed, callback) {
        fs.readFile(
            dogBreed+'.txt',    //the new file name
            'utf8',
            function(err,fileContents) {
                if (err) { callback(err); } else {
                    callback(
                        err,                                //this will be falsy since we checked it above
                        fileContents.match(/greyhound/gi)   //use RegExp to check for the string 'greyhound' in the contents of the file
                    );
                }
            }
        );
    },
    function(err, dogBreedFileContents) {
        if (err) {
            console.error(err);
        } else {
            console.log('greyhound breeds:');
            console.log(dogBreedFileContents);
        }
    }
);

此例中,我们与前面的例子更进一步。 注意看,我们是如何增加一个函数,并处理错误的。 当你需要操作异步函数的结果,但仍让async.js处理错误时,if err和callback(err)模式非常有用。

此外,你会注意到我们将err变量作为第一个参数传递给回调函数。 初一看,似乎不怎么对。 但因为我们已经检查过err的真值,我们知道了它是假的,因此可以安全地传递给回调。

越过悬崖边的瀑布

目前为止,我们已经介绍了多个有用的异步函数,且它们都有对应的同步版本。 现在,让我们投入到async.waterfall中,而它并没有同步版本。

瀑布 (waterfall) 的概念指的是一个异步函数的结果串行传递给另一个异步函数作为输入。 这是一个非常强大的概念,特别是当你需要将多个互相依赖的异步函数串起来时。 使用async.waterfall时,第一个参数是一个函数数组,第二个参数是完成回调。

在函数数组中,第一个函数总是只有一个参数,即一个回调。 后续的每个函数的参数都需要匹配前一个函数的回调函数的非err参数,再加上一个新的回调。

在我们下一个例子中,我们将利用瀑布作为粘合剂将一些概念组合起来。 在作为第一个参数的数组中,我们有三个函数:第一个加载当前目录中的目录列表,第二个作用于这个目录列表,并用async.map在每个文件上运行fs.stat,第三个函数针对第一个函数得到的目录列表,对每个文件读取文件内容 (fs.readFile)。

async.waterfall串行执行每个函数,所以它总是在执行完所有fs.stat之后再执行那些fs.readfile。 在这第一个例子中,第二和第三个函数互不依赖,所以它们可以用一个async.parallel封装起来并行执行,以减小执行时间,但我们将在下一个例子中再次修改这个结果。

注意:运行此示例时,当前目录中不要放太多文本文件,不然你的终端窗口中将会长时间出现大量垃圾文本。

代码语言:javascript
复制
var async     = require('async'),
    fs        = require('fs');
async.waterfall([
        function(callback) {
            fs.readdir('.',callback);               //read the current directory, pass it along to the next function.
        },
        function(fileNames,callback) {            //`fileNames` is the directory listing from the previous function
            async.map(
                fileNames,                             //The directory listing is just an array of filenames,
                fs.stat,                               //so we can use async.map to run fs.stat for each filename
                function(err,stats) {
                    if (err) { callback(err); } else {
                        callback(err,fileNames,stats);    //pass along the error, the directory listing and the stat collection to the next item in the waterfall
                    }
                }
            );
        },
        function(fileNames,stats,callback) {      //the directory listing, `fileNames` is joined by the collection of fs.stat objects in  `stats`
            async.map(
                fileNames,
                function(aFileName,readCallback) {    //This time we're taking the filenames with map and passing them along to fs.readFile to get the contents
                    fs.readFile(aFileName,'utf8',readCallback);
                },
                function(err,contents) {
                    if (err) { callback(err); } else {  //Now our callback will have three arguments, the original directory listing (`fileNames`), the fs.stats collection and an array of with the contents of each file
                        callback(err,fileNames,stats,contents);
                    }
                }
            );
        }
    ],
    function(err, fileNames,stats,contents) {
        if (err) {
            console.error(err);
        } else {
            console.log(fileNames);
            console.log(stats);
            console.log(contents);
        }
    }
);

比如说,我们可以让所有文本内容量为500字节。 我们在运行上面的代码时,不管你是否需要那些文本文件,每个文件的大小和内容都会被读取出来。 那么,如何只得到这些文件的文件信息,然后根据其中包含的文件大小信息来读取较小文件的内容呢?

首先,我们要将所有的匿名函数换成有名字的函数。 这只是个人偏好,但可以让代码更清晰一点,并易于理解 (可重用性也更好一些)。 你可以想像得到,我们要读取文件的大小,并评估这些大小数值,然后,根据只读取满足文件大小要求的文件。 这个任务可由Array.filter来轻松完成,但这是一个同步函数,而async.waterfall却需要异步风格的函数。 Async.js中有一个帮助函数,可将同步函数封装为异步函数,它有一个很响亮的名字:async.asyncify。

用async.asyncify封装函数只需要做三件事。 首先,从arrayFsStat函数中取出文件名和文件信息数组,然后用map将它们合并。 然后,我们过滤出文件大小小于300的那些项。 最后,我们取出绑定在一起的文件名和文件信息对象,再次用map来取出文件名。

当我们得到所有大小不起过300的文件的文件名之后,我们可用async.map和fs.readFile来得到它们的内容。 实现这个任务的方式有很多种,但我们这里将其分解开来了,以表现出最大的灵活性和可重用性。 async.waterfall的使用展示了我们如何将同步函数和异步函数混合和匹配起来。

代码语言:javascript
复制
var async     = require('async'),
    fs        = require('fs');
//Our anonymous refactored into named functions
function directoryListing(callback) {
    fs.readdir('.',callback);
}
function arrayFsStat(fileNames,callback) {
    async.map(
        fileNames,
        fs.stat,
        function(err,stats) {
            if (err) { callback(err); } else {
                callback(err,fileNames,stats);
            }
        }
    );
}
function arrayFsReadFile(fileNames,callback) {
    async.map(
        fileNames,
        function(aFileName,readCallback) {
            fs.readFile(aFileName,'utf8',readCallback);
        },
        function(err,contents) {
            if (err) { callback(err); } else {
                callback(err,contents);
            }
        }
    );
}
//These functions are synchronous
function mergeFilenameAndStat(fileNames,stats) {
    return stats.map(function(aStatObj,index) {
        aStatObj.fileName = fileNames[index];
        return aStatObj;
    });
}
function above300(combinedFilenamesAndStats) {
    return combinedFilenamesAndStats
        .filter(function(aStatObj) {
            return aStatObj.size >= 300;
        });
}
function justFilenames(combinedFilenamesAndStats) {
    return combinedFilenamesAndStats
        .map(function(aCombinedFileNameAndStatObj) {
            return aCombinedFileNameAndStatObj.fileName;
        });
}
async.waterfall([
        directoryListing,
        arrayFsStat,
        async.asyncify(mergeFilenameAndStat),   //asyncify wraps synchronous functions in a err-first callback
        async.asyncify(above300),
        async.asyncify(justFilenames),
        arrayFsReadFile
    ],
    function(err,contents) {
        if (err) {
            console.error(err);
        } else {
            console.log(contents);
        }
    }
);

更进一步,我们的函数还可以优化。 比如说,我们希望写一个与上述功能完全一样的函数,但允许灵活地选择任何路径。 与async.waterfall接近的一个函数是async.seq。 async.waterfall只是执行连接成瀑布状的一些函数,而async.seq是返回一个函数,该函数的任务是执行瀑布状函数。 除了创建一个函数,你还可以为第一个异步函数传入一个值。

迁移到async.seq只需要稍微修改一下。 首先,我们将修改directoryListing函数,它让可以接受一个路径参数。 然后,我们添加一个变量存储我们的新函数 (directoryAbove300)。 第三,我们将从async.waterfall中取出数组参数,然后将其变成async.seq的参数。 我们的瀑布函数中的完成回调,现在则成了directoryAbove300的完成回调。

代码语言:javascript
复制
var async     = require('async'),
    fs        = require('fs'),
    directoryAbove300;
function directoryListing(initialPath,callback) { //we can pass a variable into the first function used in async.seq - the resulting function can accept arguments and pass them this first function
    fs.readdir(initialPath,callback);
}
function arrayFsStat(fileNames,callback) {
    async.map(
        fileNames,
        fs.stat,
        function(err,stats) {
            if (err) { callback(err); } else {
                callback(err,fileNames,stats);
            }
        }
    );
}
function arrayFsReadFile(fileNames,callback) {
    async.map(
        fileNames,
        function(aFileName,readCallback) {
            fs.readFile(aFileName,'utf8',readCallback);
        },
        function(err,contents) {
            if (err) { callback(err); } else {
                callback(err,contents);
            }
        }
    );
}
function mergeFilenameAndStat(fileNames,stats) {
    return stats.map(function(aStatObj,index) {
        aStatObj.fileName = fileNames[index];
        return aStatObj;
    });
}
function above300(combinedFilenamesAndStats) {
    return combinedFilenamesAndStats
        .filter(function(aStatObj) {
            return aStatObj.size >= 300;
        });
}
function justFilenames(combinedFilenamesAndStats) {
    return combinedFilenamesAndStats
        .map(function(aCombinedFileNameAndStatObj) {
            return aCombinedFileNameAndStatObj.fileName;
        })
}
//async.seq will produce a new function that you can use over and over
directoryAbove300 = async.seq(
    directoryListing,
    arrayFsStat,
    async.asyncify(mergeFilenameAndStat),
    async.asyncify(above300),
    async.asyncify(justFilenames),
    arrayFsReadFile
);
directoryAbove300(
    '.',
    function(err, fileNames,stats,contents) {
        if (err) {
            console.error(err);
        } else {
            console.log(fileNames);
        }
    }
);

关于承诺 (Promises) 和异步 (Async) 函数

你也许会好奇,我为什么还没提到承诺 (promises)。 我对它们其实并没什么意见,它们非常好用,且比回调更优美。但是,它们是处理异步代码的完全不同的方式。

Node.js内置函数使用第一个参数为err的回调,而且成千上万个其它模块也使用这种模式。 事实上,这也是为什么此教程中使用fs的原因-Node.js中一些诸如文件系统这样的基础功能使用的是回调,所以不用承诺还使用回调类型的代码是Node.js编程的关键内容。

有一些相关的解决方案,比如Bluebird将第一个参数为err的回调封装为基于承诺的函数,但那又是另一个故事了(http://bluebirdjs.com/docs/api/promisification.html)。Async.js只是提供一些比喻的方式,让异步代码更为可读和可管理。

拥抱异步世界

JavaScript已经成为事实上的网络工作语言。 它不是没有学习曲线的,而且大量的框架和库也让你目不睱接。

但学习异步编程又是完全不同的事,希望本教程至少可以让你感觉到它的有用之处。

异步是编写服务器端JavaScript代码的关键所在,但如果你没有良好的习惯,你的代码将变成无法管理的回调怪兽。 通过像async.js这样的库,和它所提供的大量的比喻式的工具,你会发现编写异步代码同样有意思。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2018-09-27,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端达人 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档