Grunt拥有数量庞大的插件,这些插件能够帮助我们处理开发中遇到的绝大多数构建任务,比如代码的预编译、压缩、代码检查、单元测试等。但为什么在终端输入Grunt相关命令,就能够执行对应的任务,Grunt到底是怎么运转的?这些知识对于深入研究Grunt非常重要,下面我们从Grunt运转的组件和运转机制两方面来展开讨论。
node和npm
Grunt项目基于Node.js,Grunt和相关的插件都通过 npm 安装并管理。
Grunt-cli
Grunt命令行用于调用与Gruntfile文件在同一目录中的 Grunt模块,通过-g参数把Grunt命令行安装到全局环境中,这样的话在所有文件目录中都可以调用grunt相关的命令。
在命令行中运行Grunt 相关命令时(比如 $grunt default),内部会根据node提供的require系统查找来当前目录中安装的 Grunt,如果找到那么加载,并把加载的grunt作为参数传递到Gruntfile文件中,然后执行指定的任务。
Task
Task就是任务的意思,grunt支持自定义任务,也支持使用现成的插件任务。比如向控制台输出一句问候这可以被认为是一个Task,对所有的js文件进行压缩这也是一个Task,通常任务(Task)都是可配置的。
Grunt本地依赖
安装了grunt命令行不等于就安装了grunt,这只是让我们拥有了在命令行中使用grunt相关命令的能力,对于每个需要使用grunt的工程,仍然需要为其配置grunt本地依赖。
Grunt插件(Plugins)
Grunt插件是一系列能够用于不同项目的可配置任务的集合。Grunt插件通常以npm包的形式发布。Grunt官网的插件列表列出了所有可用的Grunt插件,截止当前的插件数量为6,393个,其中带有contrib前缀的插件由Grunt官方开发和维护。
package.json文件
package.json文件用于被npm存储项目的元数据,以便将此项目发布为npm模块。我们可以在此文件中列出项目依赖的Grunt和Grunt插件,保存在devDependencies(开发依赖)配置字段内,我们可以通过$ npm install命令来加载该文件中的所有依赖项。
Gruntfile.js文件
Gruntfile文件是Grunt项目中最核心的文件,该文件同package.json文件一起存放在项目的根目录中,主要用来配置或定义任务(task)并加载Grunt插件。标准的grunt项目中必须拥有package.json和Gruntfile这两个文件。
node_modules文件夹
node_modules文件目录存放着从远程仓库下载的grunt以及所有相关的grunt插件。
上面给出了Grunt项目中各主要组件的关系图示,是根据个人的理解绘制的,所以可能并非完全准确,但基本上已经能够说清楚Grunt的运转机制了。
我们在使用Grunt作为项目构建工具的时候,所做的事情大概可以分成三块:准备、配置、执行。
① 准备阶段
准备阶段主要进行以下操作
? node环境的安装、npm的安装(在安装node的时候默认安装)
? grunt-cli命令行的安装(通过$ npm install -g grunt-cli命令)
? 创建package.json文件(手动创建或通过$ npm init命令交互式创建)
? 配置grunt本地依赖(通过$ npm install grunt --save-dev下载grunt到项目)
? 安装需要的grunt插件(通过$ npm install grunt-contrib-xx --save-dev命令把需要的插件下载到node_modules目录)
② 配置阶段
配置阶段主要就是创建和编辑Gruntfile文件,在该文件中接收grunt参数并配置Task,注册Task。Task简单说就是任务的意思,我们可以自定义任务,也可以直接使用现成的、一些其他优秀开发者定义好并打包为node模块发布的任务(其实就是grunt插件)。
一般来说,我们总是通过grunt为我们提供的grunt.initConfig方法来对Task(插件)进行配置,如果是该Task是Grunt插件那么还需要先从node_modules目录中加载。
如果对多个Task的执行有指定的顺序或者依赖关系,那么我们可以通过grunt.registerTask方法来注册Task。
③ 执行阶段
在执行阶段,通过在命令行中输入$ grunt task名称的方式来执行指定的任务。
执行Task的时候,可以单个执行,例如:
$ grunt taskName1
$ grunt taskName2
也可以用单条命令执行多个Task,每个Task都将按照参数的传入顺序依次执行,例如:
$ grunt taskName1 taskName2
在使用构建工具的时候,这些Task具体怎么执行,执行的顺序等并非是固定不变的,需要结合特定的需求来特殊处理。如果总是有一组Task需要按顺序执行,一般可以使用grunt.registerTask方法来给这组Task设置个别名,这一组的Task以数组的形式传递。
例如:要依次执行js文件的合并、语法检查、代码压缩、css代码压缩等任务,则配置好相关Task后可以像下面这样来设置。
grunt.registerTask("customTask",["concat","jshint","uglify","cssmin"]);
要执行这组任务的时候,直接执行$ grunt customTask命令即可。
在使用Grunt的时候,可以先到Grunt官网的插件列表搜索是否有适合自己项目的Grunt插件,如果有那么建议直接使用,如果没有那么开发者可以尝试自定义任务或者是自己创建对应的插件。Grunt的插件其实就是一些封装好的任务(Task),没有什么稀奇的,Grunt支持自定义任务,而且方式非常简单。
如果我们需要定义一个任务,向控制台里输出字符串信息,那么在package.json文件、Gruntfile文件已经创建且grunt本地依赖已安装的前提下,如下编辑Gruntfile文件即可:
//包装函数 module.exports=function (grunt) { //(1)自定义任务(一) //向控制台输出:hello 文顶顶 //第一个参数:任务的名称(Task) //第二个参数:具体的任务内容 grunt.registerTask("hello",function () { grunt.log.writeln("hello 文顶顶"); }); //(2)自定义任务(二) grunt.registerTask("football",function () { grunt.log.writeln("皇家马德里: how are you!"); grunt.log.writeln("尤文图斯: how old are you!"); }); };
终端输入命令执行任务,可以单个执行,也可以一起执行,下面给出具体执行情况
wendingding:02-Grunt_Test wendingding$ grunt hello Running "hello" task hello 文顶顶 Done. wendingding:02-Grunt_Test wendingding$ grunt football Running "football" task 皇家马德里: how are you! 尤文图斯: how old are you! Done. wendingding:02-Grunt_Test wendingding$ grunt hello football Running "hello" task hello 文顶顶 Running "football" task 皇家马德里: how are you! 尤文图斯: how old are you! Done.
通过上面的代码我们可以看到,自定义任务非常简单,只需要调用grunt对象的registerTask方法即可,其中第一个参数是Task的名称,第二个参数是回调函数用来存放具体的任务(比如这里是打印输出)。
在自定义任务中,我们用到了grunt.log.writeln函数,这是Grunt提供的众多内置方法之一,作用是向控制台输出消息并换行。同类型的方法还有grunt.log.error()、grunt.log.subhead()等方法,大家可以到官网API文档自行查看。
Grunt项目在具体使用的时候,通常是自定义Task + Grunt插件相结合的形式,我们来看下面这段代码:
//包装函数 module.exports=function (grunt) { //(1)自定义任务(一) 任务名称 hello grunt.registerTask("hello",function () { grunt.log.writeln("hello 文顶顶"); }); //(2)自定义任务(二) 任务名称 football grunt.registerTask("football",function () { grunt.log.writeln("皇家马德里: how are you!"); grunt.log.writeln("尤文图斯: how old are you!"); }); //(2) 插件的处理 //使用步骤: //[1] 先把对应的插件下载和安装到本地的项目中 $ npm install grunt-contrib-concat --save-dev //[2] 对插件(任务)进行配置 grunt.initConfig //[3] 加载对应的插件 loadNpmTasks //[4] 注册任务 grunt.registerTask //[5] 通过grunt命令执行任务 //配置插件相关信息 grunt.initConfig({ "concat":{ "dist":{ "src":["src/demo_one.js","src/demo_two.js","src/demo_three.js"], "dest":"dist/index.js" } } }); //加载插件 grunt.loadNpmTasks("grunt-contrib-concat"); //注册任务(一):把hello \ football \ concat 这三个Task注册为default的Task //当执行$ grunt 或者是$ grunt default的时候,会顺序执行者三个任务! grunt.registerTask("default",["hello","football","concat"]); //注册任务(二) grunt.registerTask("customTask",["hello","football"]); };
对于上面的Gruntfile文件,如果在终端输入$ grunt或者$ grunt default 命令则依次执行hello football和concat三个任务,输入$ grunt customTask则一次执行hello football 自定义任务。
设置任务描述
随着项目复杂性的增加,Grunt任务也会越来越多,而任务(Task)的可用性、用途以及调用方法可能会变得难以追踪。所幸,我们可以通过给任务设定相应的描述信息来解决这些问题。
要给任务设置描述信息非常简单,只需要在调用registerTask方法的时候多传递一个参数即可(作为第二个参数传递),我们可以把一个具体的字符串描述信息作为函数的参数传递。
这里,我们修改上面示例代码中football任务部分的代码,并任务设置描述信息。
grunt.registerTask("football","17-18赛季 欧冠八分之一决赛抽签场景",function () { grunt.log.writeln("皇家马德里: how are you!"); grunt.log.writeln("尤文图斯: how old are you!"); });
此时,在终端中输入$ grunt --help命令就能够看到当前Grunt项目中可用的Task,以及相应的描述信息了,关键信息如下。
Available tasks hello Custom task. football 17-18赛季 欧冠八分之一决赛抽签场景 concat Concatenate files. * default Alias for "hello", "football", "concat" tasks. customTask Alias for "hello", "football" tasks.
任务依赖
在复杂的Grunt工作流程中,很多任务之间往往存在依赖关系,比如js代码的语法检查和压缩这两个任务,压缩任务需要依赖于语法检查任务,它们在执行的时候存在一定的先后关系,这种情况我们称之为任务依赖。
我们可以在注册任务的时候,刻意指定这种依赖关系,他们更多的是以一种特定的先后顺序执行。如果是自定义任务,也可以通过grunt.task.requires()方法来设定这种任务间的依赖关系。
module.exports=function (grunt) { //注册两个自定义任务 /* * 第一个参数:Task的名称 * 第二个参数:任务的描述信息 * */ grunt.registerTask("hi","描述信息:这是一个打招呼的任务",function () { grunt.log.ok("hi 文顶顶"); }); grunt.registerTask("hello","任务的描述次信息:这是一个简单问候任务",function () { //设置任务依赖:表明当前的任务在执行的时候需要依赖于另外一个任务 //必须先执行hi这个任务,才能执行hello这个任务 grunt.task.requires("hi"); console.log("Nice to meet you!"); }); };
上面的代码中定义了hi和hello两个任务,其中hello这个Task需要依赖于hi的执行,如果直接执行hello,那么会打印任务依赖的提示信息,具体的执行情况如下。
wendingding:05-Grunt项目任务的描述和依赖 wendingding$ grunt hello Running "hello" task Warning: Required task "hi" must be run first. Use --force to continue. Aborted due to warnings. wendingding:05-Grunt项目任务的描述和依赖 wendingding$ grunt hi hello Running "hi" task >> hi 文顶顶 Running "hello" task Nice to meet you! Done.
理解多目标Task
Grunt中的多目标任务(multi-task)是相对于基本任务而言的,多目标任务几乎是Grunt中最复杂的概念。它的使用方式非常灵活,其设计的目的是可以在当个项目中支持多个Targets目标[可以认为是多种配置]。当任务在执行的时候,可以一次性执行全部的Target也可以指定某一特定的Target执行。
module.exports=function (grunt) { //(1) 配置Task,给Task设置多个Target grunt.config("hello", { "targetA":{ "des":"Nice to meet you!" }, "targetB":{ "des":"how are you?" }, } ); //(2) 自定义任务 任务的名称为hello //第一个参数:Task名称 //第二个参数:任务的描述信息 //第三个参数:具体要执行的任务 grunt.registerMultiTask("hello","描述信息:打招呼",function () { grunt.log.ok("hello 文顶顶"); grunt.log.writeln("this.target:",this.target); grunt.log.writeln("this.data:",this.data); }); };
代码说明
通过观察可以发现,我们通过grunt.registerMultiTask方法创建了支持多任务(Target)操作的自定义任务hello,主要任务就是输出“hello 文顶顶”消息以及打印当前的target和data值。然后通过grunt.config方法来给hello这个Task设定了两个Target,分别是targetA和targetB。
在上面的代码中,我们引用了this.target和this.data这两个属性,回调函数中的this指向的是当前正在运行的目标对象。执行targetA这个选项的时候,打印的this对象如下:
{ nameArgs: 'hello:targetA', name: 'hello', args: [], flags: {}, async: [Function], errorCount: [Getter], requires: [Function: bound ], requiresConfig: [Function], options: [Function], target: 'targetA', data: { des: 'Nice to meet you!' }, files: [], filesSrc: [Getter] }
目前为止,我们一直在谈论Task(任务)和Target(目标),大家可能懵逼了,不禁要问它们之间到底是什么关系?
私以为可以简单的类比一下,假设现在有一个任务就是中午吃大餐,而具体吃什么大餐,可以灵活安排多个方案进行选择,比如方案A吃西餐,方案B吃中餐,方案C吃日本料理。等我们真正到了餐馆要开吃的时候,可以选择方案A吃西餐或者是方案B吃中餐,甚至中餐、西餐和日本料理全端上桌也未尝不可。
Task指的是整个任务,在这个例子中就是要吃大餐,Target指的是任务中的某一种可行方案,也就是方案A、方案B和方案C,吃大餐这个Task中我们配置了三个Target。定义任务的目的是为了执行,在执行Task的时候,我们可以选择执行某个或某几个指定的Target(目标),这样的处理方式无疑更强大而且操作起来更加的灵活。
多目标任务的执行
运行多目标Task的时候,有多种方式选择。
① 让Task按照指定的target运行。$ grunt TaskName:targetName
② 让Task把所有的target都运行一次。$ grunt TaskName
下面列出示例代码的具体执行情况
wendingding:05-Grunt项目任务的描述和依赖 wendingding$ grunt hello Running "hello:targetA" (hello) task >> hello 文顶顶 this.target: targetA this.data: { des: 'Nice to meet you!' } Running "hello:targetB" (hello) task >> hello 文顶顶 this.target: targetB this.data: { des: 'how are you?' } Done. wendingding:05-Grunt项目任务的描述和依赖 wendingding$ grunt hello:targetA Running "hello:targetA" (hello) task >> hello 文顶顶 this.target: targetA this.data: { des: 'Nice to meet you!' } Done. wendingding:05-Grunt项目任务的描述和依赖 wendingding$ grunt hello:targetB Running "hello:targetB" (hello) task >> hello 文顶顶 this.target: targetB this.data: { des: 'how are you?' } Done.
如果在Gruntfile文件中,调用了grunt.registerTask方法来注册自定义任务,那么可以通过TaskName:targetName的来方式直接指定任务的Target
//注册任务 [给hello起一个别名] grunt.registerTask("helloTargetA",["hello:targetA"]);
在终端中,输入$ grunt helloTargetA命令将会执行hello这个Task中的targetA选项。
多目标任务的Options选项
在对多目标的任务进行配置的时候,任何存储在options选项下面的数据都会被特殊的处理。
下面列出一份Gruntfile文件中的核心代码,并以多种方式执行,通过这份代码能够帮助我们理解多目标任务的Options选项配置。
//包装函数 module.exports=function (grunt) { //(1) 配置Task相关信息 /* * 第一个参数:Task的名称 * 第二个参数:任务的描述信息 * */ grunt.initConfig({ "hi": { /*对整个任务中所有target的配置项 全局配置*/ options:{ "outPut":"array" }, targetA:{ arrM:["targetA_1","targetA_2","targetA_3"] }, targetB:{ options:{ "outPut":"json" }, arrM:["targetB_1","targetB_2","targetB_3"] }, targetC:{ arrM:["targetC_1","targetC_2","targetC_3"] } } }); //(2) 自定义任务 Task名称为hi //第一个参数:Task名称 //第二个参数:任务的描述信息 //第三个参数:具体要执行的任务 grunt.registerMultiTask("hi","描述次信息:这是一个打招呼的任务",function () { console.log("任务当前执行的target: "+this.target); console.log("任务当前执行的target对应的数据: \n"); var objT=this.options(); if (objT.outPut==="array") { console.log("输出数组:\n"); console.log(this.data.arrM); }else if (objT.outPut==="json") { console.log("输出JSON数据:\n"); console.log(JSON.stringify(this.data.arrM)); } }); //(1) 相关的概念 Task(任务-hi) | target(目标) //(2) 任务的配置:任务中可以配置一个或者是多个目标 调用config //(3) 复合任务的执行(多任务-多target) // 001 grunt TaskName 把当前Task下面所有的目标操作都执行一遍 // 002 grunt TaskName:targetName 执行当前Task下面的某一个指定的目标 grunt.registerTask("default",["hi"]); };
具体的执行情况
wendingding:06-Grunt项目多任务和options wendingding$ grunt Running "hi:targetA" (hi) task 任务当前执行的target: targetA 任务当前执行的target对应的数据: 输出数组: [ 'targetA_1', 'targetA_2', 'targetA_3' ] Running "hi:targetB" (hi) task 任务当前执行的target: targetB 任务当前执行的target对应的数据: 输出JSON数据: ["targetB_1","targetB_2","targetB_3"] Running "hi:targetC" (hi) task 任务当前执行的target: targetC 任务当前执行的target对应的数据: 输出数组: [ 'targetC_1', 'targetC_2', 'targetC_3' ] Done
代码说明
上面的代码中定义了一个多目标任务,Task的名称为hi,该Task有三个target目标选项,分别是targetA、targetB和targetC。在任务配置相关代码中,全局的options配置项中outPut属性对应的值为array,表示具体的目标任务在执行的时候以数组的形式输出。
我们看到在targetB目标中重写了options选项中的outPut属性为json,当终端执行$ grunt命令的时候,会依次执行所有三个target目标选项,而targetA和targetC以数组格式来输出内容,targetB则以json格式来输出内容。
Grunt多目标任务以及选项使得我们可以针对不同的应用环境,以不同的方式来运行同一个Task。可以利用这一点,我们完全能够定义Task为不同的构建环境创建不同的输出目标。
说明 ? this.options()方法用于获取当前正在执行的目标Task的options配置选项
Grunt项目中配置模板的简单使用
在Grunt项目中,我们可以使用<% %>分隔符的方式来指定模板,当Task读取自己配置信息的时候模板的具体内容会自动扩展,且支持以递归的方式展开。
在通过<%=... %>在向模板绑定数据的时候,我们可以直接传递配置对象中的属性或调用grunt提供的方法,模板中属性的上下文就是当前的配置对象。
下面,我们通过Gruntfile文件中的一段核心代码来展现配置模板的使用情况。
module.exports=function (grunt) { //(1) 创建并设置grunt的配置对象 //配置对象:该对象将作为参数传递给grunt.config.init方法 var configObj={ concat: { target: { //src:["src/demo1.js","src/demo2.js"] src: ['<%=srcPath %>demo1.js', '<%=srcPath %>demo2.js'], //dest:["dist/2018_05_21_index.js"] dest: '<%=targetPath %>', }, }, srcPath:"src/", destPath:"dist/", targetPath:"<%=destPath %><%=grunt.template.today('yyyy_mm_dd_') %>index.js" }; //(2) 调用init方法对任务(Task)进行配置 // grunt.config.init 方法===grunt.initConfig方法 grunt.config.init(configObj); //(3) 加载concat插件 grunt.loadNpmTasks("grunt-contrib-concat"); //(4) 注册Task grunt.registerTask("default",["concat"]); };
上面这段代码对concat插件代码合并Task进行了配置,使用到了模板技术。该任务把src目录下的demo1和demo2两个js文件合并到dist目录下并命名为2018_05_21_index.js文件。
Grunt项目中导入外部的数据
在向模板绑定数据的时候,常见的做法还会导入外部的数据,并把导入的数据设置为配置对象的指定属性值。比如在开发中常常需要用到当前Grunt项目的元信息,包括名称、版本等,这些数据常通过调用grunt.file.readJSON方法加载package.json文件的方式获取。下面给出代码示例:
//包装函数 module.exports=function (grunt) { //设置(demoTask和concat)Task的配置信息 grunt.config.init({ //从package.json文件中读取项目的元(基本)信息 pkg:grunt.file.readJSON("package.json"), //demoTask的配置信息 demoTask :{ banner:"<%=pkg.name%> -- <%=pkg.version%>" }, //concat的配置信息 concat:{ options:{ stripBanners:true, banner:'/*项目名称:<%=pkg.name%> 项目版本:<%=pkg.version%> 项目的作者:<%=pkg.author%> 更新时间:<%=grunt.template.today("yyyy-mm-dd")%>*/\n' }, target:{ src:["src/demo1.js","src/demo2.js"], dest:'dist/index.js' } } }); //自定义Task 任务的名称为demoTask grunt.registerMultiTask("demoTask",function () { console.log("执行demo任务"); //表示调用config方法来读取demoTask里面的banner属性并输出 console.log(grunt.config("demoTask.banner")); }); //从node_modules目录中加载concat插件 //注意:需要先把插件下载到本地 npm install grunt-contrib-concat --save-dev grunt.loadNpmTasks("grunt-contrib-concat"); //注册任务 grunt.registerTask("default",["demoTask","concat"]); };
如果在终端输入$ grunt命令执行,那么demoTask任务将会输出grunt_demo -- 1.0.0打印消息,而concat任务则把两个js文件合并到dist目录下面的index.js文件并添加注释信息。
wendingding$ grunt Running "demoTask:banner" (demoTask) task 执行demo任务 grunt_demo -- 1.0.0 Running "concat:target" (concat) task Done. wendingding:07-Grunt项目模板配置 wendingding$ cat dist/index.js /*项目名称:grunt_demo 项目版本:1.0.0 项目的作者:文顶顶 更新时间:2018-05-21*/ console.log("demo1"); console.log("demo2");
说明 grunt.file.readJSON方法用于加载JSON数据,grunt.file.readYAML方法用于加载YAML数据。
到这里,基本上就可以说已经熟练掌握Grunt了。上文我们在进行代码演示的时候,不论是自定义任务还是Grunt插件使用的讲解都是片段性的,支离破碎的,Grunt作为一款自动化构建工具,自动化这三个字到现在还没有体现出来。
顾名思义,自动化构建的意思就是能够监听项目中指定的文件,当这些文件发生改变后自动的来执行某些特定的任务。 否则的话,每次修改文件后,都需要我们在终端里面输入对应的命令来重新执行,这顶多能算半自动化是远远不够的。
下面给出一份更全面些的Gruntfile文件,该文件中使用了几款常用的Grunt插件(uglify、cssmin、concat等)来搭建自动化构建项目的工作流。点击获取演示代码
//包装函数 module.exports=function (grunt) { // 项目配置信息 grunt.config.init({ pkg:grunt.file.readJSON("package.json"), //代码合并 concat:{ options:{ stripBanners:true, banner:'/*项目名称:<%=pkg.name%> 项目版本:<%=pkg.version%> 项目的作者:<%=pkg.author%>' +' 更新时间:<%=grunt.template.today("yyyy-mm-dd")%>*/\n' }, target:{ src:["src/demo1.js","src/demo2.js"], dest:'dist/index.js' } }, //js代码压缩 uglify:{ target:{ src:"dist/index.js", dest:"dist/index.min.js" } }, //css代码压缩 cssmin:{ target:{ src:"src/index.css", dest:"dist/index.min.css" } }, //js语法检查 jshint:{ target:['Gruntfile.js',"dist/index.js"], options:{ jshintrc:".jshintrc" } }, //监听 自动构建 watch:{ target:{ files:["src/*.js","src/*.css"], //只要指定路径的文件(js和css)发生了变化,就自动执行tasks中列出的任务 tasks:["concat","jshint","uglify","cssmin"] } } }); //通过命令行安装插件(省略...) //从node_modules路径加载插件 grunt.loadNpmTasks("grunt-contrib-concat"); grunt.loadNpmTasks("grunt-contrib-uglify"); grunt.loadNpmTasks("grunt-contrib-cssmin"); grunt.loadNpmTasks("grunt-contrib-jshint"); grunt.loadNpmTasks("grunt-contrib-watch"); //注册任务:在执行$ grunt命令的时候依次执行代码的合并|检查|压缩等任务并开启监听 grunt.registerTask("default",["concat","jshint","uglify","cssmin","watch"]) };
当在终端输入$ grunt命令的时候,grunt会执行以下任务
① 合并src/demo1.js和src/demo2.js文件并命名为index.js保存到dist目录
② 按照既定的规则对Gruntfile.js和index.js文件来进行语法检查
③ 压缩index.js文件并命名为index.min.js保存在dist目录
④ 压缩src/index.css文件并保存到dist/index.min.css
⑤ 开启监听,如果src目录下面的js文件或css文件被更改则重新构建
作者:叩丁狼教育前端学科-杨勇老师
avaScript的框架、库和工具的冒出似乎有点超出大家的想象,截止到2017年5月,在GitHub上搜索JavaScript项目,你会发现其已经超过了110万;npmjs.org上有50万个可用的软件包,每月下载量近100亿次。
为了帮助大家更好地选择JavaScript框架、库和工具,本文将对流行的框架、库和工具进行一些对比,但是由于篇幅有限,可能并不能包含到所有的框架、库和工具,所以欢迎大家在下方补充评论,共同学习进步。
为了让大家的讨论在共同的水平线上,首先我们先来确定一下框架、库和工具的概念。可能每个人对于这三者都有自己的理解,但是本文是基于以下的概念来进行讨论的。
库
库是有用功能的有组织的集合。库的典型功能包括处理字符串、日期、HTML DOM元素、事件、Cookie、动画、网络请求等。每个函数将值返回给调用应用程序,但是你可以从中选择参数来应用。如果用汽车来做比喻,那就是你可以任意使用所有的零部件来搭建汽车,但是你必须自行构建引擎。
库通常是提供一个更高的抽象层,平滑的实现细节和矛盾。例如,Ajax通常依赖于XMLHttpRequest API,但是由于各浏览器之间的差异,你可能需要修改几行代码来实现。但是库可以提供一个更简单的ajax()函数,让程序员更专注于高层次的业务逻辑。
因为库不必在意更多的细节,所以开发时间可能会缩短20%,但是它也不是没有缺点的:
库内的错误可能难以定位和修复
开发团队不能保证快速发布补丁
修补程序可能会更改API,并对您的代码进行重大更改。
框架
框架是一个应用程序的骨架,它要求你以特定的方式处理软件设计,并在某些点插入自己的逻辑。 通常框架提供事件、存储和数据绑定等功能。 如果我们还是用汽车了来做类比,那么框架就是一辆车的底盘、车身和发动机,为了让车辆始终保持运行状态,你可以添加、删除或修改某些组件。
框架通常会提供比库更高的抽象层,并且帮助用户快速构建项目的08%,但它的缺点是:
如果应用程序超出了框架的范围,那么剩下的20%可能会很难完成;
框架更新可能很困难 ;
框架核心代码和概念很少更新,但是同样的事情,程序员往往都会在短时间内发现一个更好的解决方式;
工具
工具有助于开发,但并不是项目的组成部分。 工具包括系统构建,编译器, transpilers,代码分割器,图像压缩器等。
工具的应用使得开发过程变得更加容易,例如很多程序员都喜欢将Sass to CSS,因为它提供了代码分离,嵌套,渲染时间变量,循环和函数。 浏览器不了解Sass / SCSS语法,因此在测试和部署之前,必须使用适当的工具将代码编译为CSS。
JavaScript框架和库
jQuery
jQuery是最常用的JavaScript库,它革命性的在客户端开发,将CSS选择器引入到DOM节点检索加链接来应用事件处理程序、动画和Ajax调用。jQuery近年来备受青睐,对于一个很需要JavaScript功能的项目来说,jQuery绝对是一个可行的选择。
优点:
分布规模小;
学习曲线平缓,在线帮助多;
语法简洁;
容易延伸;
缺点:
增加了本机API的速度开销
浏览器兼容性的改善降低了它的重要性;
用法扁平
有些行业反馈有很多不必要的使用。
React
React可能是去年一年最受关注的库了吧。React声称是一个用于构建用户界面的JavaScript库,它专注于MVC开发的“View”部分,并且可以轻松创建保留状态的UI组件。 它是实现虚拟DOM的第一个库, 内存结构计算差异,有效地更新页面。
从使用情况来看,React的情况似乎有些不好,但这是因为它是在应用程序中使用而不是网站,38%的程序员表示他们正在使用该库。
优点:
小巧,高效,快捷灵活;
简单的组件模型;
良好的文档和在线资源;
服务器端渲染;
处于高速发展期;
缺点:
需要学习新的概念和语法;
构建工具必不可少;
要求其他库或框架提供Model和Control;
与修改DOM的代码和其他库不兼容;
Lodash and Underscore
Lodash和Underscore提供了数百个功能性的JavaScript实用程序来补充本地字符串,数字,数组和其他原始对象方法。 它在客户端使用率较低,但是可以在服务器端的Node.js应用程序中使用很频繁。
优点:
小而简单;
拥有优质文档,易于学习;
与大多数库和框架兼容;
不扩展内置对象;
可以在客户端或服务器上使用;
缺点:
有些方法只适用于ES2015及更高版本的JavaScript。
AngularJS 1.x
Angular最流行的版本是1.x版本,它使用双向数据绑定扩展HTML,同时将DOM操作与应用程序逻辑脱钩。尽管版本2已经发布(当然现在已经到了版本4),但是Angular 1.x仍在开发中。
优点:
众多大公司采用;
以单一的解决方案来生产现代Web应用程序;
一个解决方案来生产现代Web应用程序;
MEAN堆栈(MongoDB,Express.JS,AngularJS,NodeJS),有众多文档和教程可用来参考;
缺点:
学习曲线更加陡峭;
大代码库
不能升级到Angular 2.x
Angular 2.x (now 4.x)
Angular 2.0于2016年9月发布。这是一个完整的重写,它引入了使用TypeScript(被编译为JavaScript)创建的基于模块化组件的模型。 Angular 4.0版本于2017年3月发布。
Angular2+和1.0版本截然不同,与其他也不兼容,所以也许谷歌应该给该项目另外起一个名字。
优点:
单一的解决方案来生产现代Web应用程序;
尽管Angular 2+的可用文档较少,但它仍是MEAN堆栈的一部分;
对于熟悉静态类型语言(如C#和Java)的人员,TypeScript提供了一些优势。
缺点:
更陡峭的学习曲线;
大代码库;
不能从Angular 1.x升级;
与1.x相比,Angular 2.x的使用率相对较低;
尽管是Google的项目,但Google似乎并没有使用它?
Vue.js
Vue.js是一个用于构建用户界面的轻量级渐进框架。 该核心提供了一个React-like 的虚拟 DOM-powered层,它可以与其他库集成,也可以支持单页应用程序。
Vue.js使用HTML模板语法将DOM绑定到实例数据。 模型是在更改数据时更新视图的纯JavaScript对象。 附加工具提供了scaffolding,路由,状态管理,动画等功能。
优点:
易于上手,普及度高;
起点简单,但完成满意度高;
依赖性小,性能好;
缺点:
是一个新项目,所以风险可能会很大;
依赖开发人员来更新;
相对同类框架,资源较少;
Backbone.js
Backbone.js是提供常见的服务器端框架MVC结构最早的客户端选项之一,它唯一的依赖是由同一开发人员创建的Underscore.js。
Backbone.js声称是一个库,因为它可以与其他项目集成,但我认为大多数程序员都认为它是一个框架。
优点:
体积小,重量轻,复杂度低;
不添加HTML的逻辑;
文档丰富;
采用了许多应用,包括Trello,WordPress.com,LinkedIn和Groupon;
缺点:
与AngularJS等相比,抽象度较低;
需要额外的组件来实现数据绑定等功能;
新的框架基本已经不再采用MVC架构;
Ember.js
Ember.js是基于Model-View-ViewModel(MVVM)模式的框架之一。 它在单个包中实现模板化,数据绑定和库。如果 Ruby on Rails体验的用户,能够迅速熟悉其配置概念。
优点:
为客户端应用程序提供单一解决方案;
程序员可以快速开发—其使用jQuery;
良好的向后兼容性和升级选项;
采用了现代Web开发标准;
缺点:
与其他正在向较小组件结构移动的框架相比,被认为是单一的;
陡峭的学习曲线 ;
Knockout.js
较早的MVVM框架之一,Knockout.js使用观察者来确保UI与底层数据保持同步,它具有模板和依赖关系跟踪。
优点:
小而轻便,无依赖
支持回溯到IE6
优质文档;
缺点:
较大的项目可能变得复杂;
发展速度已经放缓;
使用情况正在下降;
值得注意,下面这些项目虽然不如上面的受欢迎,但还是值得一试的:
Polymer- 可以跨浏览器支持HTML5网页组件的库
Meteor - 一个用于Web应用程序的全栈平台。
Aurelia - 一种相对较新的,轻量级的跨平台框架
Svelte - 一个将框架源代码转换为JavaScript的新项目
Conditioner.js - 一个基于状态自动加载和卸载模块的库。
工具:General-Purpose Task Runners
构建工具可以自动执行各种Web开发任务,例如预处理,编译,优化图像,缩小代码,运行测试等等。所有的任务都可以在一个可执行包中管理,比较受欢迎的工具包括:
Gulp.js
Gulp虽然不是第一个工具,但是它是最受欢迎的工具,Gulp使用易于阅读的JavaScript代码,将源文件加载到流中,并在将数据输出到构建文件夹之前通过各种插件管理数据。
npm
npm是Node.js包管理器,但其脚本工具可用于运行通用任务。 对于具有很少依赖关系的简单项目来说,这是一个有吸引力的选择,但是对于复杂的任务来说,它可能就有些有心无力。
Grunt
Grunt是第一个实现批量采用的JavaScript任务的工具,但其速度和复杂的JSON配置,使得Gulp异军突起。如今,这些问题解决了,Grunt仍然是一个不错的选择。
工具:Module Bundlers
多个JavaScript文件的管理成为了程序员们的烦恼,在默认情况下,浏览器文件未被编译,因此依赖关系必须以适当的顺序加载或连接。虽然有各种选项,如ES6模块和CommonJS,但浏览器支持毕竟是有限的,因此Module Bundlers就变得至关重要。
Webpack
Webpack支持所有流行的模块选项,并已成为React开发的代名词。 虽然它声称是一个Module Bundlers,但是也可以用作通用任务运行程序。
Browserify
Browserify支持Node.js使用的CommonJS模块,将所有模块编译成单个浏览器兼容的文件。
RequireJS
RequireJS是一种浏览器中的模块加载器,它也可以在Node.js中使用。
Tools: Linting
“Linting”是分析你的代码的潜在错误或偏离语法标准。 有了这种工具,你永远不出现只有一半括号或者未声明变量的情况。
ESLint
ESLint是一种可插拔的Linting工具,每个规则都是一个插件,因此可以根据您的喜好进行配置。
JSHint
一个灵活的JavaScript linter,在真正的错误和迂腐的语法需求之间取得了很好的平衡!
JSLint
JSLint是最早的Linter之一,遵循一套严格的默认规则。
Tools: Test Suites
在应用程序的编写过程中有一个很重要的步骤那就是代码测试。代码测试的工具有很多,如Ava、Tape和Jest。下面,我们就为大家介绍最受欢迎的三个选择:
Mocha
Mocha是一个JavaScript测试框架,可以在Node.js或浏览器中运行测试。 它支持异步测试,并且经常与Chai配对,以使测试代码能够以可读取的方式表达。
Jasmine
Jasmine是一个行为驱动的测试套件,可以在浏览器中自动测试您的UI和交互。
QUnit
QUnit是一个单元测试框架,可以通过特定参数检查函数结果。
Tools: Miscellaneous
虽然JavaScript比较常见常用,但是也并不是每个程序员都喜欢JavaScript,例如TypeScript,LiveScript和CoffeeScrip这些也可以使得程序员的开发过程很愉快。
JavaScript-powered HTML的引擎模板有数十种,其中包括Mustache,Handlebars,Pug(Jade)和EJS。但在我而言, 更喜欢保留JavaScript语法(如EJS和doT)的轻量级选项。
如何自己来编写文档呢?ES2015兼容的文档生成器包括ESDoc,JSDoc,YUIdoc,documentation.js和Transcription。
写在最后
如果你想要走在技术的前端,那么React以及和其相关的技术发展方向值得关注。如果你想要为Web应用程序选择一个安全的选项,那么你可以考虑Vue.js。
虽然整体框架现在不再那么受欢迎,但是如果你是要做严格的大型项目结构,AngularJS会是一个不错的选择。虽然,现在大多数人还在使用1.0版本,但是从长远来看,学习一下TypeScript,选择4.0版本会更加安全。
jQuery虽然在技术新闻中很少被提到,但是它的学习曲线平缓,几乎所有的程序员都可以理解,而且它现在还在积极开发。
工具的选择会因项目而异,但是不可否认,大多数项目都会选择Gulp和WebPack。每个项目和团队的技能都是不同的,所以你在选择的时候要在有限时间内准确评估。
最后,永远不要忘记库,框架和工具是可选的! JavaScript在过去的十年中发生了革命性的变化,几乎每隔几个月都会有热门框架的出现,所以很容易就掉进陷阱之中。所以,在选择时,就要考虑自己的实际需求,也要积极学习新的知识。
avaScript的框架、库和工具的冒出似乎有点超出大家的想象,截止到2017年5月,在GitHub上搜索JavaScript项目,你会发现其已经超过了110万;npmjs.org上有50万个可用的软件包,每月下载量近100亿次。
为了帮助大家更好地选择JavaScript框架、库和工具,本文将对流行的框架、库和工具进行一些对比,但是由于篇幅有限,可能并不能包含到所有的框架、库和工具,所以欢迎大家在下方补充评论,共同学习进步。
为了让大家的讨论在共同的水平线上,首先我们先来确定一下框架、库和工具的概念。可能每个人对于这三者都有自己的理解,但是本文是基于以下的概念来进行讨论的。
库
库是有用功能的有组织的集合。库的典型功能包括处理字符串、日期、HTML DOM元素、事件、Cookie、动画、网络请求等。每个函数将值返回给调用应用程序,但是你可以从中选择参数来应用。如果用汽车来做比喻,那就是你可以任意使用所有的零部件来搭建汽车,但是你必须自行构建引擎。
库通常是提供一个更高的抽象层,平滑的实现细节和矛盾。例如,Ajax通常依赖于XMLHttpRequest API,但是由于各浏览器之间的差异,你可能需要修改几行代码来实现。但是库可以提供一个更简单的ajax()函数,让程序员更专注于高层次的业务逻辑。
因为库不必在意更多的细节,所以开发时间可能会缩短20%,但是它也不是没有缺点的:
库内的错误可能难以定位和修复
开发团队不能保证快速发布补丁
修补程序可能会更改API,并对您的代码进行重大更改。
框架
框架是一个应用程序的骨架,它要求你以特定的方式处理软件设计,并在某些点插入自己的逻辑。 通常框架提供事件、存储和数据绑定等功能。 如果我们还是用汽车了来做类比,那么框架就是一辆车的底盘、车身和发动机,为了让车辆始终保持运行状态,你可以添加、删除或修改某些组件。
框架通常会提供比库更高的抽象层,并且帮助用户快速构建项目的08%,但它的缺点是:
如果应用程序超出了框架的范围,那么剩下的20%可能会很难完成;
框架更新可能很困难 ;
框架核心代码和概念很少更新,但是同样的事情,程序员往往都会在短时间内发现一个更好的解决方式;
工具
工具有助于开发,但并不是项目的组成部分。 工具包括系统构建,编译器, transpilers,代码分割器,图像压缩器等。
工具的应用使得开发过程变得更加容易,例如很多程序员都喜欢将Sass to CSS,因为它提供了代码分离,嵌套,渲染时间变量,循环和函数。 浏览器不了解Sass / SCSS语法,因此在测试和部署之前,必须使用适当的工具将代码编译为CSS。
JavaScript框架和库
jQuery
jQuery是最常用的JavaScript库,它革命性的在客户端开发,将CSS选择器引入到DOM节点检索加链接来应用事件处理程序、动画和Ajax调用。jQuery近年来备受青睐,对于一个很需要JavaScript功能的项目来说,jQuery绝对是一个可行的选择。
优点:
分布规模小;
学习曲线平缓,在线帮助多;
语法简洁;
容易延伸;
缺点:
增加了本机API的速度开销
浏览器兼容性的改善降低了它的重要性;
用法扁平
有些行业反馈有很多不必要的使用。
React
React可能是去年一年最受关注的库了吧。React声称是一个用于构建用户界面的JavaScript库,它专注于MVC开发的“View”部分,并且可以轻松创建保留状态的UI组件。 它是实现虚拟DOM的第一个库, 内存结构计算差异,有效地更新页面。
从使用情况来看,React的情况似乎有些不好,但这是因为它是在应用程序中使用而不是网站,38%的程序员表示他们正在使用该库。
优点:
小巧,高效,快捷灵活;
简单的组件模型;
良好的文档和在线资源;
服务器端渲染;
处于高速发展期;
缺点:
需要学习新的概念和语法;
构建工具必不可少;
要求其他库或框架提供Model和Control;
与修改DOM的代码和其他库不兼容;
Lodash and Underscore
Lodash和Underscore提供了数百个功能性的JavaScript实用程序来补充本地字符串,数字,数组和其他原始对象方法。 它在客户端使用率较低,但是可以在服务器端的Node.js应用程序中使用很频繁。
优点:
小而简单;
拥有优质文档,易于学习;
与大多数库和框架兼容;
不扩展内置对象;
可以在客户端或服务器上使用;
缺点:
有些方法只适用于ES2015及更高版本的JavaScript。
AngularJS 1.x
Angular最流行的版本是1.x版本,它使用双向数据绑定扩展HTML,同时将DOM操作与应用程序逻辑脱钩。尽管版本2已经发布(当然现在已经到了版本4),但是Angular 1.x仍在开发中。
优点:
众多大公司采用;
以单一的解决方案来生产现代Web应用程序;
一个解决方案来生产现代Web应用程序;
MEAN堆栈(MongoDB,Express.JS,AngularJS,NodeJS),有众多文档和教程可用来参考;
缺点:
学习曲线更加陡峭;
大代码库
不能升级到Angular 2.x
Angular 2.x (now 4.x)
Angular 2.0于2016年9月发布。这是一个完整的重写,它引入了使用TypeScript(被编译为JavaScript)创建的基于模块化组件的模型。 Angular 4.0版本于2017年3月发布。
Angular2+和1.0版本截然不同,与其他也不兼容,所以也许谷歌应该给该项目另外起一个名字。
优点:
单一的解决方案来生产现代Web应用程序;
尽管Angular 2+的可用文档较少,但它仍是MEAN堆栈的一部分;
对于熟悉静态类型语言(如C#和Java)的人员,TypeScript提供了一些优势。
缺点:
更陡峭的学习曲线;
大代码库;
不能从Angular 1.x升级;
与1.x相比,Angular 2.x的使用率相对较低;
尽管是Google的项目,但Google似乎并没有使用它?
Vue.js
Vue.js是一个用于构建用户界面的轻量级渐进框架。 该核心提供了一个React-like 的虚拟 DOM-powered层,它可以与其他库集成,也可以支持单页应用程序。
Vue.js使用HTML模板语法将DOM绑定到实例数据。 模型是在更改数据时更新视图的纯JavaScript对象。 附加工具提供了scaffolding,路由,状态管理,动画等功能。
优点:
易于上手,普及度高;
起点简单,但完成满意度高;
依赖性小,性能好;
缺点:
是一个新项目,所以风险可能会很大;
依赖开发人员来更新;
相对同类框架,资源较少;
Backbone.js
Backbone.js是提供常见的服务器端框架MVC结构最早的客户端选项之一,它唯一的依赖是由同一开发人员创建的Underscore.js。
Backbone.js声称是一个库,因为它可以与其他项目集成,但我认为大多数程序员都认为它是一个框架。
优点:
体积小,重量轻,复杂度低;
不添加HTML的逻辑;
文档丰富;
采用了许多应用,包括Trello,WordPress.com,LinkedIn和Groupon;
缺点:
与AngularJS等相比,抽象度较低;
需要额外的组件来实现数据绑定等功能;
新的框架基本已经不再采用MVC架构;
Ember.js
Ember.js是基于Model-View-ViewModel(MVVM)模式的框架之一。 它在单个包中实现模板化,数据绑定和库。如果 Ruby on Rails体验的用户,能够迅速熟悉其配置概念。
优点:
为客户端应用程序提供单一解决方案;
程序员可以快速开发—其使用jQuery;
良好的向后兼容性和升级选项;
采用了现代Web开发标准;
缺点:
与其他正在向较小组件结构移动的框架相比,被认为是单一的;
陡峭的学习曲线 ;
Knockout.js
较早的MVVM框架之一,Knockout.js使用观察者来确保UI与底层数据保持同步,它具有模板和依赖关系跟踪。
优点:
小而轻便,无依赖
支持回溯到IE6
优质文档;
缺点:
较大的项目可能变得复杂;
发展速度已经放缓;
使用情况正在下降;
值得注意,下面这些项目虽然不如上面的受欢迎,但还是值得一试的:
Polymer- 可以跨浏览器支持HTML5网页组件的库
Meteor - 一个用于Web应用程序的全栈平台。
Aurelia - 一种相对较新的,轻量级的跨平台框架
Svelte - 一个将框架源代码转换为JavaScript的新项目
Conditioner.js - 一个基于状态自动加载和卸载模块的库。
工具:General-Purpose Task Runners
构建工具可以自动执行各种Web开发任务,例如预处理,编译,优化图像,缩小代码,运行测试等等。所有的任务都可以在一个可执行包中管理,比较受欢迎的工具包括:
Gulp.js
Gulp虽然不是第一个工具,但是它是最受欢迎的工具,Gulp使用易于阅读的JavaScript代码,将源文件加载到流中,并在将数据输出到构建文件夹之前通过各种插件管理数据。
npm
npm是Node.js包管理器,但其脚本工具可用于运行通用任务。 对于具有很少依赖关系的简单项目来说,这是一个有吸引力的选择,但是对于复杂的任务来说,它可能就有些有心无力。
Grunt
Grunt是第一个实现批量采用的JavaScript任务的工具,但其速度和复杂的JSON配置,使得Gulp异军突起。如今,这些问题解决了,Grunt仍然是一个不错的选择。
工具:Module Bundlers
多个JavaScript文件的管理成为了程序员们的烦恼,在默认情况下,浏览器文件未被编译,因此依赖关系必须以适当的顺序加载或连接。虽然有各种选项,如ES6模块和CommonJS,但浏览器支持毕竟是有限的,因此Module Bundlers就变得至关重要。
Webpack
Webpack支持所有流行的模块选项,并已成为React开发的代名词。 虽然它声称是一个Module Bundlers,但是也可以用作通用任务运行程序。
Browserify
Browserify支持Node.js使用的CommonJS模块,将所有模块编译成单个浏览器兼容的文件。
RequireJS
RequireJS是一种浏览器中的模块加载器,它也可以在Node.js中使用。
Tools: Linting
“Linting”是分析你的代码的潜在错误或偏离语法标准。 有了这种工具,你永远不出现只有一半括号或者未声明变量的情况。
ESLint
ESLint是一种可插拔的Linting工具,每个规则都是一个插件,因此可以根据您的喜好进行配置。
JSHint
一个灵活的JavaScript linter,在真正的错误和迂腐的语法需求之间取得了很好的平衡!
JSLint
JSLint是最早的Linter之一,遵循一套严格的默认规则。
Tools: Test Suites
在应用程序的编写过程中有一个很重要的步骤那就是代码测试。代码测试的工具有很多,如Ava、Tape和Jest。下面,我们就为大家介绍最受欢迎的三个选择:
Mocha
Mocha是一个JavaScript测试框架,可以在Node.js或浏览器中运行测试。 它支持异步测试,并且经常与Chai配对,以使测试代码能够以可读取的方式表达。
Jasmine
Jasmine是一个行为驱动的测试套件,可以在浏览器中自动测试您的UI和交互。
QUnit
QUnit是一个单元测试框架,可以通过特定参数检查函数结果。
Tools: Miscellaneous
虽然JavaScript比较常见常用,但是也并不是每个程序员都喜欢JavaScript,例如TypeScript,LiveScript和CoffeeScrip这些也可以使得程序员的开发过程很愉快。
JavaScript-powered HTML的引擎模板有数十种,其中包括Mustache,Handlebars,Pug(Jade)和EJS。但在我而言, 更喜欢保留JavaScript语法(如EJS和doT)的轻量级选项。
如何自己来编写文档呢?ES2015兼容的文档生成器包括ESDoc,JSDoc,YUIdoc,documentation.js和Transcription。
写在最后
如果你想要走在技术的前端,那么React以及和其相关的技术发展方向值得关注。如果你想要为Web应用程序选择一个安全的选项,那么你可以考虑Vue.js。
虽然整体框架现在不再那么受欢迎,但是如果你是要做严格的大型项目结构,AngularJS会是一个不错的选择。虽然,现在大多数人还在使用1.0版本,但是从长远来看,学习一下TypeScript,选择4.0版本会更加安全。
jQuery虽然在技术新闻中很少被提到,但是它的学习曲线平缓,几乎所有的程序员都可以理解,而且它现在还在积极开发。
工具的选择会因项目而异,但是不可否认,大多数项目都会选择Gulp和WebPack。每个项目和团队的技能都是不同的,所以你在选择的时候要在有限时间内准确评估。
最后,永远不要忘记库,框架和工具是可选的! JavaScript在过去的十年中发生了革命性的变化,几乎每隔几个月都会有热门框架的出现,所以很容易就掉进陷阱之中。所以,在选择时,就要考虑自己的实际需求,也要积极学习新的知识。
*请认真填写需求信息,我们会在24小时内与您取得联系。