整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

前端渲染引擎doT.js解析

前端渲染有很多框架,而且形式和内容在不断发生变化。这些演变的背后是设计模式的变化,而归根到底是功能划分逻辑的演变:MVC—>MVP—>MVVM(忽略最早混在一起的写法,那不称为模式)。近几年兴起的React、Vue、Angular等框架都属于MVVM模式,能帮我们实现界面渲染、事件绑定、路由分发等复杂功能。但在一些只需完成数据和模板简单渲染的场合,它们就显得笨重而且学习成本较高了。

例如,在美团外卖的开发实践中,前端经常从后端接口取得长串的数据,这些数据拥有相同的样式模板,前端需要将这些数据在同一个样式模板上做重复渲染操作。

解决这个问题的模板引擎有很多,doT.js(出自女程序员Laura Doktorova之手)是其中非常优秀的一个。下表将doT.js与其他同类引擎做了对比:

框架大小压缩版本大小迭代条件表达式自定义语法
doT.js6KB4KB
mustache18.9 KB9.3 KB×
Handlebars512KB62.3KB
artTemplate(腾讯)-5.2KB
BaiduTemplate(百度)9.45KB6KB
jQuery-tmpl18.6KB5.98KB

可以看出,doT.js表现突出。而且,它的性能也很优秀,本人在Mac Pro上的用Chrome浏览器(版本为:56.0.2924.87)上做100条数据10000次渲染性能测试,结果如下:

从上可以看出doT.js更值得推荐,它的主要优势在于:

  1. 小巧精简,源代码不超过两百行,6KB的大小,压缩版只有4KB;

  2. 支持表达式丰富,涵盖几乎所有应用场景的表达式语句;

  3. 性能优秀;

  4. 不依赖第三方库。

本文主要对doT.js的源码进行分析,探究一下这类模板引擎的实现原理。

如何使用

如果之前用过doT.js,可以跳过此小节,doT.js使用示例如下:

<script type="text/html" id="tpl">

可以看出doT.js的设计思路:将数据注入到预置的视图模板中渲染,返回HTML代码段,从而得到最终视图。

下面是一些常用语法表达式对照表:

项目JavaScript语法对应语法案例
输出变量={{= 变量名}}{{=it.name }}
条件判断if{{? 条件表达式}}{{? i > 3}}
条件转折else/else if{{??}}/{{?? 表达式}}{{?? i ==2}}
循环遍历for{{~ 循环变量}}{{~ it.arr:item}}...{{~}}
执行方法funcName(){{= funcName() }}{{= it.sayHello() }}

源码分析及实现原理

和后端渲染不同,doT.js的渲染完全交由前端来进行,这样做主要有以下好处:

  1. 脱离后端渲染语言,不需要依赖后端项目的启动,从而降低了开发耦合度、提升开发效率;

  2. View层渲染逻辑全在JavaScript层实现,容易维护和修改;

  3. 数据通过接口得到,无需考虑后端数据模型变化,只需关心数据格式。

doT.js源码核心:

...

这段代码总结起来就是一句话:用正则表达式匹配预置模板中的语法规则,将其转换、拼接为可执行HTML代码,作为可执行语句,通过new Function()创建的新方法返回。

代码解析重点1:正则替换

正则替换是doT.js的核心设计思路,本文不对正则表达式做扩充讲解,仅分析doT.js的设计思路。先来看一下doT.js中用到的正则:

templateSettings: {

源码中将正则定义写到一起,这样方便了维护和管理。在早期版本的doT.js中,处理条件表达式的方式和tmpl一样,采用直接替换成可执行语句的形式,在最新版本的doT.js中,修改成仅一条正则就可以实现替换,变得更加简洁。

doT.js源码中对模板中语法正则替换的流程如下:

代码解析重点2:new Function()运用

函数定义时,一般通过Function关键字,并指定一个函数名,用以调用。在JavaScript中,函数也是对象,可以通过函数对象(Function Object)来创建。正如数组对象对应的类型是Array,日期对象对应的类型是Date一样,如下所示:

var funcName = new Function(p1,p2,...,pn,body);

参数的数据类型都是字符串,p1到pn表示所创建函数的参数名称列表,body表示所创建函数的函数体语句,funcName就是所创建函数的名称(可以不指定任何参数创建一个匿名函数)。

下面的定义是等价的。

例如:

// 一般函数定义方式

从上面的代码中可以看出,Function的最后一个参数,被转换为可执行代码,类似eval的功能。eval执行时存在浏览器性能下降、调试困难以及可能引发XSS(跨站)攻击等问题,因此不推荐使用eval执行字符串代码,new Function()恰好解决了这个问题。回过头来看doT代码中的"new Function(c.varname, str)",就不难理解varname是传入可执行字符串str的变量。

具体关于new Fcuntion的定义和用法,详细请阅读Function详细介绍。

性能之因

读到这里可能会产生一个疑问:doT.js的性能为什么在众多引擎如此突出?通过阅读其他引擎源代码,发现了它们核心代码段中都存在这样那样的问题。

jQuery-tmpl

function buildTmplFn( markup ) {

在上面的代码中看到,jQuery-teml同样使用了new Function()的方式编译模板,但是在性能对比中jQuery-teml性能相比doT.js相差甚远,出现性能瓶颈的关键在于with语句的使用。

with语句为什么对性能有这么大的影响?我们来看下面的代码:

var datas = {persons:['李明','小红','赵四','王五','张三','孙行者','马婆子'],gifts:['平民','巫师','狼','猎人','先知']};

上面代码中使用了一个with表达式,为了避免多次从datas中取变量而使用了with语句。这看起来似乎提升了效率,但却产生了一个性能问题:在JavaScript中执行方法时会产生一个执行上下文,这个执行上下文持有该方法作用域链,主要用于标识符解析。当代码流执行到一个with表达式时,运行期上下文的作用域链被临时改变了,一个新的可变对象将被创建,它包含指定对象的所有属性。此对象被插入到作用域链的最前端,意味着现在函数的所有局部变量都被推入第二个作用域链对象中,这样访问datas的属性非常快,但是访问局部变量的速度却变慢了,所以访问代价更高了,如下图所示。

这个插件在GitHub上面介绍时,作者Boris Moore着重强调两点设计思路:

  1. 模板缓存,在模板重复使用时,直接使用内存中缓存的模板。在本文作者看来,这是一个鸡肋的功能,在实际使用中,无论是直接写在String中的模板还是从Dom获取的模板都会以变量的形式存放在内存中,变量使用得当,在页面整个生命周期内都能取到这个模板。通过源码分析之后发现jQuery-tmpl的模板缓存并不是对模板编译结果进行缓存,并且会造成多次执行渲染时产生多次编译,再加上代码with性能消耗,严重拖慢整个渲染过程。

  2. 模板标记,可以从缓存模板中取出对应子节点。这是一个不错的设计思路,可以实现数据改变只重新渲染局部界面的功能。但是我觉得:模板将渲染结果交给开发者,并渲染到界面指定位置之后,模板引擎的工作就应该结束了,剩下的对节点操作应该灵活的掌握在开发者手上。

不改变原来设计思路基础之上,尝试对源代码进行性能提升。

先保留提升前性能作为对比:

首先来我们做第一次性能提升,移除源码中with语句。

第一次提升后:

接下来第二部提升,落实Boris Moore设计理念中的模板缓存:

优化后的这一部分代码段被我们修改成了:

 function buildTmplFn( markup ) {

在doT.js源码中没有用到with这类消耗性能的语句,与此同时doT.js选择先将模板编译结果返回给开发者,这样如要重复多次使用同一模板进行渲染便不会反复编译。

仅25行的模板:tmpl

(function(){

阅读这段代码会惊奇的发现,它更像是baiduTemplate精简版。相比baiduTemplate而言,它移除了baiduTemplate的自定义语法标签的功能,使得代码更加精简,也避开了替换用户语法标签而带来的性能消耗。对于doT.js来说,性能问题的关键是with语句。

综合上述我对tmpl的源码进行移除with语句改造:

改造之前性能:

改造之后性能:

如果读者对性能对比源码比较感兴趣可以访问 https://github.com/chen2009277025/TemplateTest 。

总结

通过对doT.js源码的解读,我们发现:

  1. doT.js的条件判断语法标签不直观。当开发者在使用过程中条件判断嵌套过多时,很难找到对应的结束语法符号,开发者需要自己严格规范代码书写,否则会给开发和维护带来困难。

  2. doT.js限制开发者自定义语法标签,相比较之下baiduTemplate提供可自定义标签的功能,而baiduTemplate的性能瓶颈恰好是提供自定义语法标签的功能。

很多解决我们问题的插件的代码往往简单明了,那些庞大的插件反而存在负面影响或无用功能。技术领域有一个软件设计范式:“约定大于配置”,旨在减少软件开发人员需要做决定的数量,做到简单而又不失灵活。在插件编写过程中开发者应多注意使用场景和性能的有机结合,使用恰当的语法,尽可能减少开发者的配置,不求迎合各个场景。

作者简介

建辉,美团外卖高级前端研发工程师,2015年加入美团点评外卖事业部。目前在前端业务增长组,主要负责运营平台搭建,主导运营活动业务。

欢迎大家一起沟通交流,博客Hi-FE。

不想错过技术博客更新?想给文章评论、和作者互动?第一时间获取技术沙龙信息?

请关注我们的官方微信公众号“美团点评技术团队”。

为一个使用了jQuery很多年 的人,最近,我成为了一个Vue的皈依者,我认为从一个框架到另一个框架的迁移过程将是一个值得讨论的有趣的话题。

在我开始之前,我想清楚地说明一点。我并没有以任何方式告诉任何人去停止使用jQuery。jQuery最近相当流行,而且见鬼,我几年前也写过类似的东西(“我如何(不)使用jQuery”)。如果你使用jQuery完成了你的项目,并且你的最终用户成功地使用了你的站点,那么你将获得更多的动力去继续使用对你有用的东西。

本指南更适合那些可能具有多年jQuery经验并希望了解如何使用Vue来完成工作的人。考虑到这一点,我将重点介绍我所认为的“核心”jQuery用例。我不会涵盖每一个可能的特性,而是用“我经常使用jQuery来完成 [X]”的方式来代替,这种方式可能更适合那些考虑学习Vue的人。(顺便提一句,请注意,我编写示例的方式只是执行一个任务的一种方式。jQuery和Vue都提供了多种方法来实现相同的目标,这是一件很棒的事情!)

记住了这一点,我们来思考一些可以使用jQuery完成的高级的东西:

  • 在DOM中找到某些东西(稍后再用它做一些事情)
  • 修改DOM中的某些东西(例如一个段落的文本或一个按钮的类)
  • 读取和设置表单值
  • 表单验证(实际上是上面各项的一个组合)
  • ajax调用和处理结果
  • 事件处理(例如点击按钮,做某些事情)
  • 测量或改变元素的样式

当然,jQuery还有更多的功能,但是这些用途(至少在我看来)涵盖了最常见的用例。还要注意,在上面的列表中有很多异花授粉现象。那么,我们应该从简单的一一对应的比较开始吗?不,没那么快。我们先从介绍Vue应用程序中的主要差异开始。

#定义Vue的使用场景

当我们将jQuery加入到页面上时,我们基本上是在JavaScript代码中添加一把瑞士军刀来处理常见的web开发任务。我们可以按照我们认为合适的顺序来处理任何一个用例。例如,今天客户可能会要求表单验证,然后在一个月左右后,又要求在站点的头部添加一个基于Ajax的搜索表单。

Vue在这方面有一个显著的不同。当使用Vue开始一个项目时,我们首先会在DOM中定义一个我们希望Vue专用的“区域”。因此,我们来考虑一个简单的原型web页面:

在一个典型的jQuery应用程序中,我们可以编写代码来处理头部、侧边栏和登录表单等。这很简单:

而在一个Vue应用程序中,我们首先需要指定要处理的内容。假设我们的客户首先要求我们向loginForm元素添加验证,那么我们的Vue代码就要指定这一点:

这意味着,如果客户后来决定让我们在侧边栏中添加一些内容,那我们通常会添加第二个Vue应用程序:

这是件坏事吗?绝对不是。我们马上就会得到封装的好处。如果我们不小心使用了一个具有泛型名称的变量(我们都这样做过),我们不必担心它与代码的其他部分发生冲突。过后,当客户端增加了另一个要求时,像这样将我们独特的、逻辑化的Vue代码集区分开就会确保每一个Vue应用程序不会相互干扰。

所以,是的,这是一件好事。但当我第一次开始使用Vue时,它绝对让我停了下来。现在,进入我们的用例。

#在DOM中查找东西

你会发现另一个有趣或可怕的方面是如何“在DOM中查找东西”。这有点模糊,但我们来考虑一个强有力的例子。我们有一个按钮,当它被点击时,我们让一些事情发生。下面是一个简短的例子,展示了它是怎样的:

现在我们来将这个例子与用Vue的实现方式进行比较:

这个Vue应用程序有点冗长,但是请注意标记是如何在操作(“click”)和将要调用的函数之间建立一个直接连接的。Vue的代码并没有与DOM进行向后绑定(我们在el部分之外定义了它需要运行的地方)。这是Vue最吸引我的地方之一——它能很容易地告诉你将要发生什么。此外,我不需要过多地担心ID值和选择器。如果我更改了按钮的类或ID,我不需要返回代码中去更新选择器。

我们来考虑另一个例子:在DOM中查找和更改文本。想象一下那个按钮,单击它,现在会更改DOM的另一部分的文本。

我已经添加了一个新的span,现在,当按钮被单击时,我们使用另一个选择器来查找它,并使用一个jQuery工具方法来更改其中的文本。现在我们来考虑一下Vue版本:

在本例中,我们使用Vue的模板语言(突出显示的行)来指定我们希望在span中呈现的一个变量,在本例中是resultText。现在,当按钮被单击时,我们更改该值,span的内部文本将会自动更改。

顺便说一句,Vue支持v-on属性的简写,因此示例中的按钮可以用@click=“ doSomething"代替。

#读写表单变量

处理表单可能是我们可以用JavaScript做的最常见也是最有用的事情之一。甚至在JavaScript之前,我早期的“web开发”大部分都是通过编写Perl脚本来处理表单提交。作为接受用户输入的主要方式,表单对web来说一直都是很重要的,而且很可能会在相当长一段时间内保持不变。我们来考虑一个简单的jQuery例子,它将读取几个表单字段并设置另一个:

这段代码演示了jQuery如何通过val( )方法读写表单。最后,我们从DOM中获取四个项目(所有的三个表单字段和一个按钮),并使用简单的数学方法来生成一个结果。现在我们来考虑一下Vue版本:

这里介绍了一些有趣的Vue快捷方法。首先,v-model是Vue如何在DOM和JavaScript中的值之间创建双向数据绑定。data块变量将自动与表单字段同步。更改数据,表单就会更新。更改表单,数据就会更新。.number是Vue的一个标志,用于将表单字段的继承字符串值视为数字。如果我们不做这一步,按原样做加法,我们会看到字符串加法,而不是算术。我已经使用JavaScript处理了将近一个世纪了,但还是搞砸了。

另一个简单的“技巧”是@click.prevent。首先,@click为按钮定义了一个单击处理程序,然后.prevent部分会阻止浏览器提交表单的默认行为(相当于event.preventDefault( ))。

最后一个是绑定到该按钮的doSum方法进行的相加操作。注意,它只处理数据变量(Vue在this作用域内允许对这些变量进行操作)。

虽然这主要是我个人的感觉,但我非常喜欢在用Vue编写脚本时,脚本中没有查询选择器,以及HTML如何更清楚地显示它在做什么。

最后,我们甚至可以完全去掉按钮:

Vue的一个更酷的特性是computed properties(计算属性)。它们是虚拟值,可以识别其派生值何时被更新。在上面的代码中,只要两个表单字段中的任何一个发生更改,总和就会更新。这也适用于表单字段之外。我们可以这样渲染其总和:

#使用Ajax

值得称赞的是,jQuery使Ajax的使用变得非常简单。事实上,我可以说我已经以一种“普通”的方式完成了Ajax,可能总共只有一次(如果你对此很好奇,你可以查看XMLHttpRequest规范,并且你可能会为你已经避免了它而感到高兴)。jQuery简单的$.get(…)方法在很多情况下都能工作,并且当它需要在更复杂的东西中使用时,$.ajax()也能使它变得简单。jQuery做得很好的另一件事是它处理JSONP请求的方式。虽然现在使用CORS基本上没有必要,但JSONP是一种处理向不同域中的API发出请求的方法。

那么,Vue如何让Ajax变得更简单呢?没有什么!

好吧,听起来很吓人,但其实并不可怕。有许多处理HTTP请求的选项,而Vue.js采用了一种更不可知的方式,让我们开发人员决定如何处理它。所以,是的,这确实意味着更多的工作,但我们有一些不错的选择。

首先应该考虑的是Axios,这是一个Promise-based库,在Vue社区中非常流行。下面是一个使用它的简单的例子(摘自它们的README文件):

Axios支持POST请求,当然,它也允许我们在许多其他选项中指定头文件。

虽然Axios在Vue开发人员中非常流行,但我并不是真心喜欢它。(至少现在还没有。)相反,我更喜欢Fetch。Fetch不是一个外部库,而是执行HTTP请求的一种web标准方法。Fetch在大约90%的浏览器

上都有很好的支持,虽然这意味着使用它并不完全安全,但是我们总是可以使用一个我们需要的polyfill。

虽然这完全超出了我们在这里讨论的范围,但是Kingsley Silas写了一本关于在React中使用Axios和Fetch的优秀指南。

和Axios一样,Fetch也是Promise-based的,并且有一个友好的API:

Axios和Fetch都涵盖了所有类型的HTTP请求,所以它们都能满足任意数量的需求。让我们看一个简单的比较。下面是一个使用了星球大战API的简单jQuery演示。

在上面的示例中,我使用$.get调用该API并返回一个电影列表。然后我用这些数据生成一个标题列表作为 li 标记元素,并将其全部插入到一个ul块中。

现在,让我们考虑一个使用Vue的例子:

其中最好的部分可能是使用v-for模板。注意Vue是如何做到与布局无关的(至少与JavaScript无关)。数据是从该API中获取的。它被分配了一个变量。布局处理如何显示它。我一直讨厌在我的JavaScript中使用HTML,但是jQuery提供了解决方案,把它嵌入到Vue中看起来就很自然很合适。

#一个完整的(在某种程度上有点琐碎)例子

为了更好地理解它,让我们考虑一个更真实的例子。我们的客户要求我们为一个产品API构建一个支持Ajax的前端搜索接口。功能列表包括:

  • 支持按名称和产品类别进行过滤
  • 我们必须提供搜索项或类别的表单验证
  • 当API被点击时,向用户显示一条消息并禁用提交按钮
  • 完成后,对未显示产品进行报告或列出匹配项

我们从jQuery版本开始。首先, HTML部分如下:

有一个带有两个过滤器和两个div的表单。一个用做搜索或报告错误时的临时状态,另一个用于呈现结果。现在,检查代码。

代码首先为要处理的每个DOM项(表单字段、按钮和div)创建一组变量。代码的逻辑核心在按钮的点击处理程序中。我们进行验证,如果一切正常,就对该API执行一个POST请求。当请求返回时,我们要么呈现结果

,要么在没有匹配的情况下显示消息。

你可以使用下面的CodePen来运行这个演示的完整版本。

现在让我们考虑Vue版本。同样,我们先从布局开始:

从顶部看,其中的变化包括:

  • 用一个div包装布局,可以让Vue知道在哪里运行。
  • 对表单字段使用v-model,以便它能轻松处理数据。
  • 使用@click.prevent处理执行主搜索操作。
  • 使用 :disabled 在这个Vue应用程序中将按钮绑定到一个值,无论按钮是否禁用 (我们稍后将看到它的实际操作)。
  • 状态值与前面的示例稍有不同。jQuery有一个特定的方法来设置DOM项中的文本和HTML中的文本,而Vue在将HTML分配给要呈现的值时需要使用v-html。如果我们在HTML中直接编写{{status}},标签将被转义。
  • 最后,使用v-if条件性地呈现结果列表,同时使用v-for来处理迭代。

现在让我们看看代码。

值得调用的第一个块是data字段集。有些映射到表单字段,有些映射到结果、状态消息等等。searchProducts方法处理的内容与jQuery版本大致相同,但通常直接绑定到DOM的代码要少得多。例如,即使我们知道结果是以一个无序列表列出的,但代码本身并不关心这一点。它只是进行赋值,标记才处理呈现值。总的来说,与jQuery代码相比,JavaScript代码更关心逻辑,jQuery代码“感觉”是更好的分离了关注点。

和以前一样,这里有一个CodePen可以让你自己试试:

#jQuery将死! Vue万岁!

好吧,这有点过分了。正如我在开始时所说的,如果你喜欢使用jQuery并且它对你有用的话,那我觉得你完全没必要更改任何东西。

不过,我想说的是,对于习惯使用jQuery的人来说,Vue似乎是一个很好的“下一步”。Vue支持复杂的应用程序,并为搭建和构建项目提供了一个非常棒的命令行工具。但是对于更简单的任务来说,Vue是一个很棒的“现代jQuery”的替代品,它已经成为我开发的可选工具!

有关使用Vue替代jQuery的另一个观点,请查看Sarah Drasner的“使用Vue.js替换jQuery:无需构建步骤”,因为它包含了其他一些超级有用的例子。

英文原文:https://css-tricks.com/making-the-move-from-jquery-to-vue/

译者:浣熊君( ・᷄৺・᷅ )

者 | 司徒正美

责编 | 郭芮

出品 | CSDN(ID:CSDNnews)

JavaScript能发展到现在的程度已经经历不少的坎坷,早产带来的某些缺陷是永久性的,因此浏览器才有禁用JavaScript的选项。甚至在jQuery时代有人问出这样的问题,jQuery与JavaScript哪个快?在Babel.js出来之前,发明一门全新的语言代码代替JavaScript的呼声一直不绝于耳,前有VBScript,Coffee, 后有Dartjs, WebAssembly。要不是它是所有浏览器都内置的脚本语言, 可能就命绝于此。浏览器就是它的那个有钱的丈母娘。此外源源不断的类库框架,则是它的武器库,从底层革新了它自己。为什么这么说呢?

JavaScript没有其他语言那样庞大的SDK,针对某一个领域自带的方法是很少,比如说数组方法,字符串方法,都不超过20个,是Prototype.js给它加上的。JavaScript要实现页面动效,离不开DOM与BOM,但浏览器互相竞争,导致API不一致,是jQuery搞定了,还带来了链式调用与IIFE这些新的编程技巧。在它缺乏大规模编程模式的时候,其他语言的外来户又给它带来了MVC与MVVM……这里面许多东西,久而久之都变成语言内置的特性,比如Prototype.js带来的原型方法,jQuery带来的选择器方法,实现MVVM不可缺少的对象属性内省机制(getter, setter, Reflect, Proxy), 大规模编程需要的class, modules。

本文将以下几个方面介绍这些新特性,正是它们武装了JavaScript,让它变成一个正统的,魔幻的语言。

  • 原型方法的极大丰富;

  • 类与模块的标准化;

  • 异步机制的嬗变;

  • 块级作用域的补完;

  • 基础类型的增加;

  • 反射机制的完善;

  • 更顺手的语法糖。

原型方法的极大丰富

原型方法自Prototype.js出来后,就不断被招安成官方API。基本上在字符串与数组这两大类别扩充,它们在日常业务中不断被使用,因此不断变重复造轮子,因此企待官方化。

JavaScript的版本说明:

这些原型方法非常有用,以致于在面试中经常被问到,如果去除字符串两边的空白,如何扁平化一个数组?

类与模块的标准化

在没有类的时代,每个流行框架都会带一个创建类的方法,可见大家都不太认同原型这种复用机制。

下面是原型与类的写法比较:

function Person(name) {

this.name = name;

}

//定义一个方法并且赋值给构造函数的原型

Person.prototype.sayName = function {

return this.name;

};

var p = new Person('ruby');

console.log(p.sayName) // ruby

class Person {

constructor(name){

this.name = name

}

sayName {

return this.name;

}

}

var p = new Person('ruby');

console.log(p.sayName) // ruby

我们可以看到es6的定义是非常简单的,并且不同于对象键值定义方式,它是使用对象简写来描述方法。如果是标准的对象描述法,应该是这样:

//下面这种写法并不合法

class Person {

constructor: function(name){

this.name = name

}

sayName: function {

return this.name;

}

}

如果我们想继承一个父类,也很简单:

class Person extends Animal {

constructor: function(name){

super;

this.name = name

}

sayName: function {

return this.name;

}

}

此外,它后面还补充了三次相关的语法,分别是属性初始化语法,静态属性与方法语法,私有属性语法。目前私有属性语法争议非常大,但还是被标准化。虽然像typescript的private、public、protected更符合从后端转行过来的人的口味,不过在babel无所不能的今天,我们完全可以使用自己喜欢的写法。

与类一起出现的还有模块,这是一种比类更大的复用单元,以文件为载体,可以实现按需加载。当然它最主要的作用是减少全局污染。jQuery时代,通过IIFE减少了这症状,但是JS文件没有统一的编写规范,意味着想把它们打包一个是非常困难的,只能像下面那样平铺着。这些文件的依赖关系,只有最初的人知道,要了几轮开发后,就是定时炸弹。此外,不要忘记,<script>标准还会导致页面渲染堵塞,出现白屏现象。

<script src="zepto.js"></script>

<script src="jhash.js"></script>

<script src="fastClick.js"></script>

<script src="iScroll.js"></script>

<script src="underscore.js"></script>

<script src="handlebar.js"></script>

<script src="datacenter.js"></script>

<script src="util/wxbridge.js"></script>

<script src="util/login.js"></script>

<script src="util/base.js"></script>

于是后jQuery时代,国内流行三种模块机制,以seajs主体的CMD,以requirejs为主体的AMD,及nodejs自带的Commonjs。当然,后来还有一种三合一方案UMD(AMD, Commonjs与es6 modules)。

requirejs的定义与使用:

define(['jquery'], function($){

//some code

var mod = require("./relative/name");

return {

//some code

} //返回值可以是对象、函数等

})

require(['cores/cores1', 'cores/cores2', 'utils/utils1', 'utils/utils2'], function(cores1, cores2, utils1, utils2){

//some code

})

requirejs是世界第一款通用的模块加载器,尤其自创了shim机制,让许多不模范的JS文件也可以纳入其加载系统。

define(function(require){

var $ = require("jquery");

$("#container").html("hello,seajs");

var service = require("./service")

var s = new service;

s.hello;

});

//另一个独立的文件service.js

define(function(require,exports,module){

function Service{

console.log("this is service module");

}

Service.prototype.hello = function{

console.log("this is hello service");

return this;

}

module.exports = Service;

});

Seajs是阿里大牛玉伯加的加载器,借鉴了Requiejs的许多功能,听说其性能与严谨性超过前者。当前为了正确分析出define回调里面的require语句,还发起了一个 100 美刀赏金活动,让国内高手一展身手。

  • https://github.com/seajs/seajs/issues/478

image_1doan2vfl17ld1nin1hbm182c9b9p.png-72.9kB

相对而言,nodejs模块系统就简单多了,它没有专门用于包裹用户代码的define方法,它不需要显式声明依赖。

//world.js

exports.world = function {

console.log('Hello World');

}

//main.js

let world = require('./world.js')

world;

function Hello {

var name;

this.setName = function(thyName) {

name = thyName;

};

this.sayHello = function {

console.log('Hello ' + name);

};

};

module.exports = Hello;

而官方钦点的es6 modules与nodejs模块系统极其相似,只是将其方法与对象变成关键字。

//test.js或test.mjs

import * as test from './test';

//aaa.js或aaa.mjs

import {aaa} from "./aaa"

const arr = [1, 2, 3, 4];

const obj = {

a: 0,

b: function {}

}

export const foo = => {

const a = 0;

const b = 20;

return a + b;

}

export default {

num,

arr,

obj,

foo

}

那怎么使用呢?根据规范,浏览器需要在link标签与script标签添加新的属性或属性值来支持这新特性。(详见:https://www.jianshu.com/p/f7db50cf956f)

<link rel="modulepreload" href="lib.mjs">

<link rel="modulepreload" href="main.mjs">

<script type="module" src="main.mjs"></script>

<script nomodule src="fallback.js"></script>

但可惜的是,浏览器对模块系统的支持是非常滞后,并且即便最新的浏览器支持了,我们还是免不了要兼容旧的浏览器。对此,我们只能奠出webpack这利器,它是前端工程化的集大成者,可以将我们的代码通过各种loader/plugin打包成主流浏览器都认识的JavaScript语法,并以最原始的方式挂载进去。

异步机制的嬗变

在JavaScript没有大规模应用前,用到异步的地方只有ajax请求与动画,在请求结束与动画结束时要做什么事,使用的办法是经典的回调。

回调

由于javascript是单线程的,我们的方法是同步的,像下面这样,一个个执行:

A;

B;

C;

而异步则是不可预测其触发时机:

A;

// 在现在发送请求

ajax({

url: url,

data: {},

success:function(res){

// 在未来某个时刻执行

B(res)

}

})

C;

//执行顺序:A -> C -> B

回调函数是主函数的后继方法,基本上能保证,主函数执行后,它能在之后某个时刻被执行一次。但随着功能的细分,在微信小程序或快应用中,它们拆分成三个,即一个方法跟着三个回调。

// https://doc.quickapp.cn/features/system/share.html

import share from '@system.share'

share.share({

type: 'text/html',

data: '<b>bold</b>',

success: function{},

fail: function{},

complete: function{}

})

在nodejs中,内置的异步方法都是使用一种叫Error-first回调模式。

fs.readFile('/foo.txt', function(err, data) {

// TODO: Error Handling Still Needed!

console.log(data);

});

在后端,由于存在IO操作,异步操作非常多,异步套异步很容易造成回调地狱。于是出现了另一种模式,事件中心,EventBus或EventEmiiter。

var EventEmitter = require('events').EventEmitter;

var ee = new EventEmitter;

ee.on('some_events', function(foo, bar) {

console.log("第1个监听事件,参数foo=" + foo + ",bar="+bar );

});

console.log('第一轮');

ee.emit('some_events', 'Wilson', 'Zhong');

console.log('第二轮');

ee.emit('some_events', 'Wilson', 'Z');

事件可以一次绑定,多次触发,并且可以将原来内部的回调拖出来,有效地避免了回调地狱。但事件中心,对于同一种行为,总是解发一种回调,不能像小程序的回调那么清晰。于是jQuery引进了Promise。

Promise

Promise最初叫Deffered,从Python的Twisted框架中引进过来。它通过异步方式完成用类的构建,又通过链式调用解决了回调地狱问题。

var p = new Promise(function(resolve, reject){

console.log("========")

setTimeout(function{

resolve(1)

},300)

setTimeout(function{

//reject与resolve只能二选一

reject(1)

},400)

});

console.log("这个先执行")

p.then(function (result) {

console.log('成功:' + result);

})

.catch(function (reason) {

console.log('失败:' + reason);

}).finally(function{

console.log("总会执行")

})

为什么这么说呢?看上面的示例,new Promise(executor)里的executor方法,它会待到then, catch, finally等方法添加完,才会执行,它是异步的。而then, catch, finally则又恰好对应success, fail, complete这三种回调,我们可以为Promise以链式方式添加多个then方法。

如果你不想写catch,新锐的浏览器还提供了一个新事件做统一处理:

window.addEventListener('unhandledrejection', function(event) {

// the event object has two special properties:

alert(event.promise); // [object Promise] - 产生错误的 promise

alert(event.reason); // Error: Whoops! - 未处理的错误对象

});

new Promise(function {

throw new Error("Whoops!");

}); // 没有 catch 处理错误

nodejs也有相同的事件:

process.on('unhandledRejection', (reason, promise) => {

console.log('未处理的拒绝:', promise, '原因:', reason);

// 记录日志、抛出错误、或其他逻辑。

});

除此之外,esma2020年还为Promise添加了三个静态方法:Promise.all和Promise.race,Promise.allSettled 。

其实chrome 60已经都可以用了。

Promise.all(iterable) 方法返回一个 Promise 实例,此实例在 iterable 参数内所有的 promise 都“完成(resolved)”或参数中不包含 promise 时回调完成(resolve);如果参数中 promise 有一个失败(rejected),此实例回调失败(reject),失败原因的是第一个失败 promise 的结果。

var promise1 = Promise.resolve(3);

var promise2 = 42;

var promise3 = new Promise(function(resolve, reject) {

setTimeout(resolve, 100, 'foo');

});

Promise.all([promise1, promise2, promise3]).then(function(values) {

console.log(values);

});

// expected output: Array [3, 42, "foo"]

这个方法类似于jQuery.when,专门用于处理并发事务。

Promise.race(iterable) 方法返回一个 promise,一旦迭代器中的某个promise解决或拒绝,返回的 promise就会解决或拒绝。此方法用于竞态的情况。

Promise.allSettled(iterable)方法返回一个promise,该promise在所有给定的promise已被解析或被拒绝后解析,并且每个对象都描述每个promise的结果。它类似于Promise.all,但不会因为一个reject就会执行后继回调,必须所有promise都被执行才会。

Promise不并比EventBus, 回调等优异,但是它给前端API提供了一个标杠,以后处理异步就是返回一个Promise。为后来async/await做了铺垫。

生成器

生成器generator, 不是为解决异步问题而诞生的,只是恰好它的某个特性可以解耦异步的复杂性,加之koa的暴红,人们发现原来generator还可以这样用,于是就火了。

为了理解生成器的含义,我们需要先了解迭代器,迭代器中的迭代就是循环的意思。比如es5中的forEach, map, filter就是迭代器。

let numbers = [1, 2, 3];

for (let i = 0; i < numbers.length; i++) {

console.log(numbers[i]);

}

//它比上面更精简

numbers.forEach(function(el){

console.log(el);

})

但forEach会一下子把所有元素都遍历出来,而我们喜欢一个个处理呢?那我们就要手写一个迭代器。

function makeIterator(array){

var nextIndex = 0;

return {

next: function{

return nextIndex < array.length ?

{value: array[nextIndex++], done: false} :

{done: true};

}

};

}

var it = makeIterator([1,2,3])

console.log(it.next); // {value: 1, done: false}

console.log(it.next); // {value: 2, done: false}

console.log(it.next); // {value: 3, done: false}

console.log(it.next); // {done: true}

而生成器则将创建迭代器常用的模式官方化,就像创建类一样,但是它写法有点怪,不像类那样专门弄一个关键字,也没有像Promise那样弄一个类。

//理想中是这样的

Iterator{

exector{

yield 1;

yield 2;

yield 3;

}

}

//现实是这样的

function* Iterator {

yield 1;

yield 2;

yield 3;

}

其实最好是像Promise那样,弄一个类,那么我们还可以用现成的语法来模拟,但生成器,现在一个新关键字yield,你可以将它当一个return语句。生成器执行后,会产生一个对象,它有一个next方法,next方法执行多少次,就轮到第几个yield的值返回。

function* Iterator {

yield 1;

yield 2;

yield 3;

}

let it = Iterator;

console.log(it.next); // {value: 1, done: false}

console.log(it.next); // {value: 2, done: false}

console.log(it.next); // {value: 3, done: false}

console.log(it.next); // {value: undefined, done: true}

由于写法比较离经背道,因此通常见于类库框架,业务中很少有人使用。它涉及许多细节,比如说yield与return的混用。

function* generator {

yield 1;

return 2; //这个被转换成 yield 2, 并立即设置成done: true

yield 3; //这个被忽略

}

let it = generator;

console.log(it.next); // {value: 1, done: false}

console.log(it.next); // {value: 2, done: true}

console.log(it.next); // {value: undefined, done: true}

image_1doda17jkj7kl4u1qru1era2m316.png-322.9kB

但说了这么多,这与异步有什么关系呢?我们之所以需要回调,事件,Promise这些,其实是希望能实现以同步代码的方式组件异步逻辑。yield相当一个断点,能中断程序往下执行。于是异步的逻辑就可以这样写:

function* generator {

yield setTimeout(function{ console.log("111"), 200})

yield setTimeout(function{ console.log("222"), 100})

}

let it = generator;

console.log(it.next); // 1 视浏览器有所差异

console.log(it.next); // 2 视浏览器有所差异

如果没有yield,肯定是先打出222,再打出111。

好了,我们搞定异步代码以同步代码的顺序输出后,就处理手动执行next方法的问题。这个也简单,写一个方法,用程序执行它们。

function timeout(data, time){

return new Promise(function(resolve){

setTimeout(function{

console.log(data, new Date - 0)

resolve(data)

},time)

})

}

function *generator{

let p1 = yield timeout(1, 2000)

console.log(p1)

let p2 = yield timeout(2, 3000)

console.log(p2)

let p3 = yield timeout(3, 2000)

console.log(p3)

return 2;

}

// 按顺序输出 1 2 3

/* 传入要执行的gen */

/* 其实循环遍历所有的yeild (函数的递归)

根绝next返回值中的done判断是否执行到最后一个,

如果是最后一个则跳出去*/

function run(fn) {

var gen = fn;

function next(data) {

// 执行gen.next 初始data为undefined

var result = gen.next(data)

// 如果result.done 为true

if(result.done) {

return result.value

}else{

// result.value 为promise

result.value.then(val=>{

next(val)

})

}

}

// 调用上一个next方法

next;

}

run(generator)

koa早些年的版本依赖的co库,就是基于上述原理摆平异步问题。有兴趣的同学可以下来看看。

async/await

上节章的生成器已经完美地解决异步的逻辑以同步的代码编写的问题了,什么异常,可以直接try catch,成功则直接往下走,总是执行可以加finally语句,美中不足是需要对yield后的方法做些改造,改成Promise(这个也有库,在nodejs直接内置了util.promisefy)。然后需要一个run方法,代替手动next。于是处于语言供应链上流的大佬们想,能不能直接将这两步内置呢?然后包装一个已经被人接受的语法提供给没有见过世面的前端工程师呢?他们搜刮了一遍,还真有这东西。那就是C#有async/await。

//C# 代码

public static async Task<int> AddAsync(int n, int m) {

int val = await Task.Run( => Add(n, m));

return val;

}

这种没有学习成本的语法很快迁移到JS中,async关键字,相当于生成器函数与我们自造的执行函数,await关键字相当于yield,但它只有在它跟着的是Promise才会中断流程执行。async函数最后会返回一个Promise,可以供外面的await关键字使用。

//javascript 代码

async function addTask {

await new Promise(function(resolve){

setTimeout(function{ console.log("111"); resolve, 200})

})

console.log('222')

await new Promise(function(resolve){

setTimeout(function{ console.log("333"); resolve, 200})

})

console.log('444')

}

var p = addTask

console.log(p)

image_1dodd79nc1imnnm91q1b1p7qhdp1j.png-6.1kB

在循环中使用async/await:

const array = ["a","b", "c"]

function getNum(num){

return new Promise(function(resolve){

setTimeout(function{

resolve(num)

}, 300)

})

}

async function asyncLoop {

console.log("start")

for(let i = 0; i < array.length; i++){

const num = await getNum(array[i]);

console.log(num, new Date-0)

}

console.log("end")

}

asyncLoop

async函数里面的错误也可以用try catch包住,也可以使用上面提到的unhandledrejection方法。

async function addTask {

try{

await ...

console.log('222')

}catch(e){

console.log(e)

}

}

此外,es2018还添加了异步迭代器与异步生成器函数,让我们处理各种异步场景更加得心应手:

//异步迭代器

const ruby = {

[Symbol.asyncIterator]: => {

const items = [`r`, `u`, `b`, `y`, `l`, `o`,`u`, `v`, `r`, `e`];

return {

next: => Promise.resolve({

done: items.length === 0,

value: items.shift

})

}

}

}

for await (const item of ruby) {

console.log(item)

}

//异步生成器函数,async函数与生成器函数的混合体

async function* readLines(path) {

let file = await fileOpen(path);

try {

while (!file.EOF) {

yield await file.readLine;

}

} finally {

await file.close;

}

}

块级作用域的补完

说起作用域,大家一般认为JavaScript只有全局作用域与函数作用域,但是es3时代,它还是能通过catch语句与with语句创造块级作用域的。

try{

var name = 'global' //全局作用域

}catch(e){

var b = "xxx"

console.log(b)//xxx

}

console.log(b)

var obj = {

name: "block"

}

with(obj) {

console.log(name);//Block块上的name block

}

console.log(name)//global

但是catch语句执行后,还是会污染外面的作用域,并且catch是很耗性能的。而with更不用说了,会引起歧义,被es5严格模式禁止了。

话又说回来,之所以需要块状作用域,是用来解决es3的两个不好的设计,一个是变量提升,一个重复定义,它们都不利于团队协作与大规模生产。

var x = 1;

function rain{

alert( x ); //弹出 'undefined',而不是1

var x = 'rain-man';

alert( x ); //弹出 'rain-man'

}

rain;

因此到es6中,新添了let和const关键字来实现块级作用域。这两个关键字相比var,有如下特点:

  1. 作用域是局部,作用范围是括起它的两个花括号间,即for{},while{},if{}与单纯的{}

  2. 它也不会提升到作用域顶部,它顶部到定义的那一行变称之为“暂时性死区”,这时使用它会报错。

  3. 变量一旦变let, const声明,就再不能重复定义,否则也报错。这种严格的错误提示对我们调试是非常有帮助的。

let a = "hey I am outside";

if(true){

//此处存在暂时性死区

console.log(a);//Uncaught ReferenceError: a is not defined

let a = "hey I am inside";

}

//let与const不存在变量提升

console.log(a); // Uncaught ReferenceError: a is not defined

console.log(b); // Uncaught ReferenceError: b is not defined

let a = 1; //Uncaught SyntaxError: Identifier 'a' has already been declared

const b = 2;

//不存在变量提升,因此块级作用域外层无法访问

if(true){

var bar = "bar";

let baz = "baz";

const qux = "qux";

}

console.log(bar);//bar

console.log(baz);//baz is not defined

console.log(qux);//qux is not defined

const声明则比let声明多了一个功能,就让目标变量的值不能再次改变,即其他语言的常量。

基础类型的增加

在javascript, 我们通过typeof与Object.prototype.toString.call可以区分出对象的类型,过去总有7种类型:undefined, , string, number, boolean, function, object。现在又多出两个类型,一个是es6引进的Symbol,另一个是es2019的bBigInt。

console.log(typeof 9007199254740991n); // "bigint"

console.log(typeof Symbol("aaa")); // "symbol"

Symbol拥有三个特性,创建的值是独一无二的,附加在对象是不可遍历的,不支持隐式转换。此外Symbol上面还有其他静态方法,用来为对象扩展更多功能。

我们先看它如何表示独一无二的属性值。如果没有Symbol,我们寻常表示常量的方法是不可靠的。

const COLOR_GREEN = 1

const COLOR_RED = 2

const LALALA = 1;

function isSafe(args) {

if (args === COLOR_RED) return false

if (args === COLOR_GREEN) return true

throw new Error(`非法的传参: ${args}`)

}

console.log(isSafe(COLOR_GREEN)) //true

console.log(isSafe(COLOR_RED)) //false

console.log(isSafe(LALALA)) //true

如果是Symbol,则符合我们的预期:

const COLOR_GREEN = Symbol("1")//传参可以是字符串,数字,布尔或不填

const COLOR_RED = Symbol("2")

const LALALA = Symbol("1")

function isSafe(args) {

if (args === COLOR_RED) return false

if (args === COLOR_GREEN) return true

throw new Error(`非法的传参: ${args}`)

}

console.log(isSafe(COLOR_GREEN)) //true

console.log(isSafe(COLOR_RED)) //false

console.log(COLOR_GREEN == LALALA) //false

console.log(isSafe(LALALA)) //throw error

注意,Symbol不是一个构造器,不能new。new Symbel("222")会抛错。

第二点,过往的对象属性都是字符串类型,如果我们没有用Object.defineProperty做处理,它们都能直接用for in遍历出来。而Symbol属性不一样,遍历不出来,因此适用做对象的私有属性,因为你只有知道它的名字,才能访问到它。

var a = {

b: 11,

c: 22

}

var d = Symbol;

a[d] = 33

for(var i in a){

console.log(i, a[i]) //只有b,c

}

第三点,以往的数据类型都可以与字符串相加,变成一个字符串,或者减去一个数字,隐式转换为数字;而Symbol则直接抛错。

ar d = Symbol("11")

console.log(d - 1)

我们再来看它的静态方法:

Symbol.for

这类似一个Symbol, 但是它不表示独一无二的值,如果用Symbor.for创建了一个symbol, 下次再用相同的参数来访问,是返回相同的symbol。

Symbol.for("foo"); // 创建一个 symbol 并放入 symbol 注册表中,键为 "foo"

Symbol.for("foo"); // 从 symbol 注册表中读取键为"foo"的 symbol

Symbol.for("bar") === Symbol.for("bar"); // true,证明了上面说的

Symbol("bar") === Symbol("bar"); // false,Symbol 函数每次都会返回新的一个 symbol

var sym = Symbol.for("mario");

sym.toString;

上面例子是从火狐官方文档拿出来的,提到注册表这样的东西,换言之,我们所有由Symbol.for创建的symbol都由一个内部对象所管理。

Symbol.keyFor

Symbol.keyFor方法返回一个已注册的 symbol 类型值的key。key就是我们的传参,也等于同于symbol的description属性。

let s1 = Symbol.for("111");

console.log( Symbol.keyFor(s1) ) // "111"

console.log(s1.description) // "111"

let s2 = Symbol("222");

console.log( Symbol.keyFor(s2)) // undefined

console.log(s2.description) // "222"

let s3 = Symbol.for(111);

console.log( Symbol.keyFor(s3) ) // "111"

console.log(s3.description) // "111"

需要注意的是,Symbol.for为 Symbol 值登记的名字,是全局环境的,可以在不同的 iframe 或 service worker 中取到同一个值。

iframe = document.createElement('iframe');

iframe.src = String(window.location);

document.body.appendChild(iframe);

iframe.contentWindow.Symbol.for('111') === Symbol.for('111')// true

Symbol.iterator

在es6中添加了for of循环,相对于for in循环,它是直接遍历出值。究其原因,是因为数组原型上添加Symbol.iterator,它就是一个内置的迭代器,而for of就是执行函数的语法。像数组,字符串,arguments, NodeList, TypeArray, Set, Map, WeakSet, WeatMap的原型都加上Symbol.iterator,因此都可以用for of循环。

console.log(Symbol.iterator in new String('sss')) // 将简单类型包装成对象才能使用in

console.log(Symbol.iterator in [1,2,3] )

console.log(Symbol.iterator in new Set(['a','b','c','a']))

for(var i of "123"){

console.log(i) //1,2 3

}

但我们对普通对象进行for of循环则遇到异常,需要我们自行添加。

Object.prototype[Symbol.iterator] = function {

var keys = Object.keys(this);

var index = 0;

return {

next: => {

var obj = {

value: this[keys[index]],

done: index+1 > keys.length

};

index++;

return obj;

}

};

};

var a = {

name:'ruby',

age:13,

home:"广东"

}

for (var val of a) {

console.log(val);

}

Symbol.asyncIterator

Symbol.asyncIterator与for await of循环一起使用,见上面异步一节。

Symbol.replace、search、split

这几个静态属性都与正则有关,我们会发现这个方法名在字符串也有相同的脸孔,它们就是改变这些方法的行为,让它们能接收一个对象,这些对象有相应的symbol保护方法。具体见下面例子:

class Search1 {

constructor(value) {

this.value = value;

}

[Symbol.search](string) {

return string.indexOf(this.value);

}

}

console.log('foobar'.search(new Search1('bar')));

class Replace1 {

constructor(value) {

this.value = value;

}

[Symbol.replace](string) {

return `s/${string}/${this.value}/g`;

}

}

console.log('foo'.replace(new Replace1('bar')));

class Split1 {

constructor(value) {

this.value = value;

}

[Symbol.split](string) {

var index = string.indexOf(this.value);

return this.value + string.substr(0, index) + "/"

+ string.substr(index + this.value.length);

}

}

console.log('foobar'.split(new Split1('foo')));

Symbol.toStringTag

可以决定自定义类的 Object.prototype.toString.call的结果:

class ValidatorClass {

get [Symbol.toStringTag] {

return 'Validator';

}

}

console.log(Object.prototype.toString.call(new ValidatorClass));

// expected output: "[object Validator]"

此外,还有许多静态属性, 方便我们对语言的底层做更精致的制定,这里就不一一罗列了。

我们再看BigInt, 它就没有这么复杂。早期JavaScript的整数范围是2的53次方减一的正负数,如果超过这范围,数值就不准确了。

console.log(1234567890123456789 * 123) //这显然不对

因此我们非常需要这样的数据类型,在它没有出来前只能使用字符串来模拟。然后chrome67中,已经内置这种类型了。想使用它,可能直接在数字后加一个n,或者使用BigInt创建它。

const theBiggestInt = 9007199254740991n;

const alsoHuge = BigInt(9007199254740991);

// ↪ 9007199254740991n

const hugeString = BigInt("9007199254740991");

// ↪ 9007199254740991n

const hugeHex = BigInt("0x1fffffffffffff");

// ↪ 9007199254740991n

const hugeBin = BigInt("0b11111111111111111111111111111111111111111111111111111");

console.log(typeof hugeBin) //bigint

反射机制的完善

反射机制指的是程序在运行时能够获取自身的信息。例如一个对象能够在运行时知道自己哪些属性被执行了什么操作。

最先映入我们眼帘的是IE8带来的get, set关键字。这就是其他语言的setter, getter。看似是一个属性,其实是两个方法。

var inner = 0;

var obj = {

set a(val){

console.log("set a ")

inner = val

},

get a{

console.log("get a ")

return inner +2

}

}

console.log(obj)

obj.a = 111

console.log(obj.a) // 113

image_1dojfhdi1vqbdqg1hr4mkt52h9.png-11.9kB

但在babel.js还没有诞生的年代,新语法是很难生存的,因此IE8又搞了两个类似的API,用来定义setter, getter:Object.defineProperty与Object.defineProperties。后者是前者的强化版。

var inner = 0;

var obj = {}

Object.defineProperty(obj, 'a', {

set:function(val){

console.log("set a ")

inner = val

},

get: function{

console.log("get a ")

return inner +2

}

})

console.log(obj)

obj.a = 111

console.log(obj.a) // 113

而标准浏览器怎么办?IE8时代,firefox一方也有相应的私有实现:__defineGetter____defineSetter__,它们是挂在对象的原型链上。

var inner = 0;

var obj = {}

obj.__defineSetter__("a", function(val){

console.log("set a ")

inner = val

})

obj.__defineGetter__("a", function{

console.log("get a ")

return inner + 4

})

console.log(obj)

obj.a = 111

console.log(obj.a) // 115

在三大框架没有崛起之前,是MVVM的狂欢时代,avalon等框架就是使用这些方法实现了MVVM中的VM。

setter与getter是IE停滞十多年瀦中添加的一个重要特性,让JavaScript变得现代化,也更加魔幻。

但它们只能监听对象属性的赋值取值,如果一个对象开始没有定义,后来添加就监听不到;我们删除一个对象属性也监听不到;我们对数组push进一个元素也监听不到,对某个类进行实例化也监听不到……总之,局b限还是很大的。于是chrome某个版本添加了Object.observe,支持异步监听对象的各种举动(如"add", "update", "delete", "reconfigure", "setPrototype", "preventExtensions"),但是其他浏览器不支持,于是esma委员会又合计搞了另一个逆天的东西Proxy。

Proxy

这个是es6大名鼎鼎的魔术代理对象,与Object.defineProperty一样,无法以旧有方法来模拟它。

下面是它的用法,其拦截器所代表的操作:

let p = new Proxy({}, {//拦截对象,上面有如下拦截器

get: function(target, name){

// obj.aaa

},

set: function(target, name, value){

// obj.aaa = bbb

},

construct: function(target, args) {

//new

},

apply: function(target, thisArg, args) {

//执行某个方法

},

defineProperty: function (target, name, descriptor) {

// Object.defineProperty

},

deleteProperty: function (target, name) {

//delete

},

has: function (target, name) {

// in

},

ownKeys: function (target, name) {

// Object.getOwnPropertyNames

// Object.getOwnPropertySymbols

// Object.keys Reflect.ownKeys

},

isExtensible: function(target) {

// Object.isExtensible。

},

preventExtensions: function(target) {

// Object.preventExtensions

},

getOwnPropertyDescriptor: function(target, prop) {

// Object.getOwnPropertyDescriptor

},

getPrototypeOf: function(target){

// Object.getPrototypeOf,

// Reflect.getPrototypeOf,

// __proto__

// Object.prototype.isPrototypeOf与instanceof

},

setPrototypeOf: function(target, prototype) {

// Object.setPrototypeOf.

}

});

这个对象在vue3, mobx中被大量使用。

Reflect

Reflect与Proxy一同推出,Reflect上的方法与Proxy的拦截器同名,用于一些Object.xxx操作与in, new , delete等关键字的操作(这时只是将它们变成函数方式)。换言之,Proxy是接活的,Reflect是干活的,火狐官网的示例也体现这一点。

var p = new Proxy({

a: 11

}, {

deleteProperty: function (target, name) {

console.log(arguments)

return Reflect.deleteProperty(target, name)

}

})

delete p.a

它们与Object.xxx最大的区别是,它们都有返回结果, 并且传参错误不会报错(如Object.defineProperty)。可能官方认为将这些元操作方法放到Object上有点不妥,于是推出了Reflect。

Reflect总共有13个静态方法:

Reflect.apply(target, thisArg, args)

Reflect.construct(target, args)

Reflect.get(target, name, receiver)

Reflect.set(target, name, value, receiver)

Reflect.defineProperty(target, name, desc)

Reflect.deleteProperty(target, name)

Reflect.has(target, name)

Reflect.ownKeys(target)

Reflect.isExtensible(target)

Reflect.preventExtensions(target)

Reflect.getOwnPropertyDescriptor(target, name)

Reflect.getPrototypeOf(target)

Reflect.setPrototypeOf(target, prototype)

更顺手的语法糖

除了添加这些方法外,JavaScript底层的parser也大动手术,让它支持更多语法糖。语法糖都可以写成对应的函数,但不方便。总的来说,语法糖是想让大家的代码更加精简。

新近添加如下语法糖:

  • 对象简写,参看类的组织形式

  • 扩展运算符(),用于对象的浅拷贝

  • 箭头函数,省略function关键字,与数学公式走近,能绑定this与略去return

  • for of(遍历可迭代对象的所有值, for in是遍历对象的键或索引)

  • 数字格式化, 如1_222_333

  • 字符串模板化与天然多行支持,如hello ${world}

  • 幂运算符, **

  • 可选链,let x = foo?.bar.baz;

  • 空值合并运算符, let x = foo ?? bar;

  • 函数的默认参数

总结

ECMAScript正在快速发展,经常会有新特性被引入,有兴趣可以查询babel的语法插件(https://www.babeljs.cn/docs/plugins),了解更详细的用法。相信有了这些新特征的支持,大家再也不敢看小JavaScript了。

作者简介:司徒正美,拥有十年纯前端经验,著有《JavaScript框架设计》一书,去哪儿网公共技术部前端架构师。爱好开源,拥有mass、Avalon、nanachi等前端框架。目前在主导公司的小程序、快应用的研发项目。

【END】