toString:伟大而可怕

图片


JavaScript中toString函数可能是js开发人员本身和外部观察者中讨论的最“隐式”的。 她是关于许多可疑算术运算的无数笑话和模因的原因,这些运算进入了stupor [object Object]的转换。 也许只有在使用float64时才感到惊讶。


我不得不观察,使用或克服的有趣案例促使我写了一份真正的汇报。 我们将深入探讨语言规范,并使用示例分析toString的非显而易见功能。


如果您希望获得有用且足够的指导,那么thisthisthat材料更适合您。 如果您的好奇心仍然胜过实用主义,那么请谨慎行事。


所有你需要知道的


简单来说, toString函数是Object原型对象的属性,它是其方法。 它用于对象的字符串转换,并且应该以良好的方式返回原始值。 原型对象也有其实现: Function,Array,String,Boolean,Number,Symbol,Date,RegExp,Error 。 如果实现原型对象(类),则toString将是一个很好的形式。


JavaScript是一种类型系统较弱的语言:这意味着它使我们可以混合使用不同类型,并隐式执行许多操作。 在转换中, toStringvalueOf配对以将对象简化为操作所需的原语。 例如,如果运算符之间至少有一行,则加法运算符将变为串联。 在工作之前,该语言的一些标准功能导致该字符串的参数: parseInt,decodeURI,JSON.parse,btoa等。


关于隐式转换,已经有很多说法和嘲笑了。 我们将考虑关键语言原型对象的toString的实现。


Object.prototype.toString


如果我们转到规范的相应部分,则会发现默认toString的主要任务是获取所谓的标签以连接到结果字符串:


"[object " + tag + "]" 

为此:


  1. 可以访问内部的toStringTag符号(或旧版本中的伪属性[[Class]] ):它具有许多内置的原型对象( Map,Math,JSON和其他)。
  2. 如果缺少或不是一个字符串,则列举许多其他表示对象类型的内部伪属性和方法: [[Call]]表示功能[[DateValue]]表示日期,依此类推。
  3. 好吧,如果什么都没有,那么标记“ Object”

那些受到反射影响的人会立即注意到可以通过简单的操作来获得对象类型的可能性(规范不建议这样做,但有可能):


 const getObjT = obj => Object.prototype.toString.call(obj).match(/\[object\s(\w+)]/)[1]; 

默认toString的特殊之处在于它可以与任何this值一起使用。 如果它是基元,则它将被强制转换为对象(分别检查nullundefined )。 没有TypeError


 [Infinity, null, x => 1, new Date, function*(){}].map(getObjT); > ["Number", "Null", "Function", "Date", "GeneratorFunction"] 

这怎么派上用场? 例如,在开发用于动态代码分析的工具时。 在应用程序的工作过程中使用了临时的变量池,您可以在运行时收集有用的同类统计信息。


这种方法有一个主要缺点:用户类型。 不难猜测,对于他们的实例,我们只会得到“ Object”


自定义Symbol.toStringTag和Function.name


JavaScript中的OOP基于原型,而不是基于类(例如Java),并且我们没有现成的getClass()方法。 为用户类型明确定义toStringTag字符将有助于解决问题:


 class Cat { get [Symbol.toStringTag]() { return 'Cat'; } } 

或以原型样式:


 function Dog(){} Dog.prototype[Symbol.toStringTag] = 'Dog'; 

还有一个替代解决方案,它是只读属性Function.name ,它不是规范的一部分,但大多数浏览器都支持。 原型对象/类的每个实例都有一个指向创建它的构造函数的链接。 这样我们就可以找到类型的名称:


 class Cat {} (new Cat).constructor.name < 'Cat' 

或以原型样式:


 function Dog() {} (new Dog).constructor.name < 'Dog' 

当然,该解决方案不适用于使用匿名函数( “ anonymous” )或Object.create(null)创建的对象,也不适用于没有包装对象的原语( null,undefined )。


因此,为了可靠地操作变量类型,值得结合主要基于手头任务的众所周知的技术。 在大多数情况下, typeofinstanceof足够了。


Function.prototype.toString


我们有些分心,但是结果是我们得到了具有自己有趣的toString的函数。 首先,看下面的代码:


 (function() { console.log('(' + arguments.callee.toString() + ')()'); })() 

许多人可能猜测这是Quine的一个例子。 如果将包含此类内容的脚本加载到页面的主体中,则源代码的精确副本将显示在控制台中。 这是由于arguments.callee函数调用了toString所致。


Function原型对象 toString的使用实现返回函数源代码的字符串表示形式,并保留其定义中使用的语法: FunctionDeclaration,FunctionExpression,ClassDeclaration,ArrowFunction等。


例如,我们有一个箭头功能:


 const bind = (f, ctx) => function() { return f.apply(ctx, arguments); } 

调用bind.toString()将返回一个ArrowFunction的字符串表示形式


 "(f, ctx) => function() { return f.apply(ctx, arguments); }" 

从包装函数中调用toString已经是FunctionExpression的字符串表示形式:


 "function() { return f.apply(ctx, arguments); }" 

这个绑定示例并非偶然,因为我们有一个现成的解决方案,它具有上下文绑定Function.prototype.bind ,对于本机绑定函数, Function.prototype.toString具有与之配合使用的功能 。 根据实现方式,可以获得包装函数本身和目标函数的表示。 V8和SpiderMonkey chrome和ff的最新版本:


 function getx() { return this.x; } getx.bind({ x: 1 }).toString() < "function () { [native code] }" 

因此,应谨慎使用本地装饰的功能。


练习使用f.toString


有关使用toString的方法有很多,但是仅作为元编程工具或调试时,它才是紧迫的。 具有类似业务逻辑的典型应用程序迟早会导致无法支持的断槽。


我想到的最简单的事情是确定函数的长度


 f.toString().replace(/\s+/g, ' ').length 

toString结果的空白字符的位置和数量由规范指定给特定的实现,因此,为了整洁起见,我们首先删除多余的部分,以形成一个整体视图。 顺便说一下,在旧版本的Gecko引擎中,该函数具有一个特殊的缩进参数,该参数有助于格式化缩进。


立即想到函数参数名称定义 ,可以很方便地进行反思:


 f.toString().match(/^function(?:\s+\w+)?\s*\(([^\)]+)/m)[1].split(/\s*,\s*/) 

此膝盖解决方案适用于FunctionDeclarationFunctionExpression语法。 如果需要更详细和准确的信息,建议您查找自己喜欢的框架的源代码示例,该框架可能根据声明的参数名称在内部进行某种依赖注入。


通过eval 覆盖函数的危险而有趣的选择:


 const sum = (a, b) => a + b; const prod = eval(sum.toString().replace(/\+(?=\s*(?:a|b))/gm, '*')); sum(5, 10) < 15 prod(5, 10) < 50 

知道原始函数的结构后,我们通过用乘法参数替换其主体中使用的加法运算符来创建了一个新函数。 在软件生成的代码或缺少功能扩展接口的情况下,这可能会非常有用。 例如,如果要研究数学模型,则选择一个合适的函数,并运算符和系数。


更实际的用途是模板的编译和分发 。 许多模板引擎实现会编译模板的源代码,并提供已经形成最终HTML(或其他HTML)的数据功能。 以下是_.template函数的示例:


 const helloJst = "Hello, <%= user %>" _.template(helloJst)({ user: 'admin' }) < "Hello, admin" 

但是,如果编译模板需要硬件资源或客户端非常薄怎么办? 在这种情况下,我们可以在服务器端编译模板,并且不向客户端提供模板文本,而是为完成功能的字符串表示形式。 此外,您无需在客户端上加载模板库。


 const helloStr = _.template(helloJst).toString() helloStr < "function(obj) { obj || (obj = {}); var __t, __p = ''; with (obj) { __p += 'Hello, ' + ((__t = ( user )) == null ? '' : __t); } return __p }" 

现在,我们需要在客户端上执行此代码,然后再使用。 由于FunctionExpression语法,在编译时没有SyntaxError


 const helloFn = eval(helloStr.replace(/^function\(obj\)/, 'obj=>')); 

左右:


 const helloFn = eval(`const f = ${helloStr};f`); 

或如您所愿。 无论如何:


 helloFn({ user: 'admin' }) < "Hello, admin" 

在服务器端编译模板并将其进一步分发给客户端时,这可能不是最佳实践。 只是使用一堆Function.prototype.toStringeval的示例。


最后,通过toString 定义函数名称的旧任务(在Function.name属性出现之前):


 f.toString().match(/function\s+(\w+)(?=\s*\()/m)[1] 

当然,这可以与FunctionDeclaration语法配合使用。 一个更聪明的解决方案将需要巧妙的正则表达式或模式匹配。


只需问一下,Internet上就充满了基于Function.prototype.toString的有趣解决方案。 在评论中分享您的经验:非常有趣。


Array.prototype.toString


Array原型对象 toString 的实现是通用的,可以为任何对象调用。 如果对象具有join方法,则toString的结果将是其调用,否则为Object.prototype.toString


从逻辑上讲, Array具有一个join方法 ,该方法通过作为参数传递的分隔符将其所有元素的字符串表示形式连接起来(默认为逗号)。


假设我们需要编写一个序列化其参数列表的函数。 如果所有参数都是基元,那么在很多情况下我们都可以不使用JSON.stringify


 function seria() { return Array.from(arguments).toString(); } 

左右:


 const seria = (...a) => a.toString(); 

只要记住字符串“ 10”和数字10会被序列化为相同。 在某一阶段最短的记忆程序问题中,使用了此解决方案。


数组元素的本机连接通过从0到长度的算术循环工作,并且不会过滤缺少的元素( nullundefined )。 取而代之的是,发生分隔符 。 这导致以下结果:


 const ar = new Array(1000); ar.toString() < ",,,...,,," // 1000 times 

因此,如果出于某种原因将具有较大索引的元素添加到数组(例如,这是生成的自然ID),则在任何情况下都不要加入,因此,如果没有进行初步准备,就不会导致字符串。 否则,可能会导致以下后果: 无效的字符串长度,内存不足或只是悬空的脚本。 使用对象的功能对象值键仅迭代其自身的对象枚举属性:


 const k = []; k[2**10] = 1; k[2**20] = 2; k[2**30] = 3; Object.values(k).toString() < "1,2,3" Object.keys(k).toString() < "1024,1048576,1073741824" 

但是最好避免对数组进行这种处理:最有可能的是,一个简单的键值对象将适合您作为存储对象。


顺便说一句,通过JSON.stringify进行序列化时存在相同的危险。 更严重的是,因为空的和不受支持的元素已经表示为“ null”


 const ar = new Array(1000); JSON.stringify(ar); < "[null,null,null,...,null,null,null]" // 1000 times 

最后,我想提醒您,您可以为用户类型定义join方法,并调用Array.prototype.toString.call作为字符串的替代强制转换,但是我怀疑它是否有实际用途。


Number.prototype.toString和parseInt


我最喜欢的JS测验任务之一是什么将返回下一个parseInt调用?


 parseInt(10**30, 2) 

parseInt做的第一件事是通过调用抽象函数ToString隐式地将参数转换为字符串,该函数根据参数的类型执行所需的转换分支。 对于类型number ,执行以下操作:


  1. 如果值为NaN,0Infinity ,则返回相应的字符串。
  2. 否则,该算法将返回最方便的数字记录:十进制或指数形式。

在这里,我将不重复用于确定首选格式的算法,仅注意以下几点:如果十进制表示形式的位数超过21 ,则将选择指数格式。 这意味着在我们的情况下parseInt不适用于“ 100 ... 000”,而适用于“ 1e30”。 因此,答案根本不是2 ^ 30。 谁知道这个魔术数字21的性质-写下!


接下来, parseInt查看使用的基数系统的基数 (默认为10,我们有2),并检查接收到的字符串的字符是否兼容。 遇到“ e”后,它会剪掉整条尾巴,仅留下“ 1”。 结果将是一个整数,该整数是通过将系统从带有基数的基数转换为十进制而获得的,在本例中为1。


逆向程序:


 (2**30).toString(2) 

这是从Number原型对象调用toString函数的地方,该对象使用相同的算法将数字转换为字符串。 它还具有可选的基数参数。 仅当它parseInt返回NaN时 ,它才会为无效值(必须是2到36之间的整数)抛出RangeError


如果您计划实现一个奇异的散列函数,请记住数字系统的上限:这个toString可能对您不起作用。


分心的任务:


 '3113'.split('').map(parseInt) 

将返回什么以及如何解决?


失去关注


我们绝不检查toString甚至所有本机原型对象。 在某种程度上,因为我个人不必麻烦他们,他们也没什么有趣的。 另外,我们没有碰到toLocaleString函数,因为单独讨论它会很不错。 如果我做了一些徒劳无益的事情,被忽视,看不见或被误解-请务必写信!


呼吁无所作为


我列举的例子绝不是现成的菜谱,只是让人深思。 另外,我发现在技术采访中讨论这一点毫无意义,也有点愚蠢:为此,存在着关于闭包,联接,事件循环,模块/外观/中介者模式的永恒主题,以及关于[使用的框架]的“当然”问题。


这篇文章原来是大杂烩,希望您自己发现了一些有趣的东西。 PS JavaScript语言-太神奇了!


红利


在准备发布该材料时,我使用了Google翻译。 偶然地,我发现了一种有趣的效果。 如果您选择俄语翻译成英语,请输入“ toString”并开始使用Backspace键删除它,那么我们将观察到:


红利


真讽刺! 我想我距离第一个还差,但以防万一我给他们发送了带有播放脚本的屏幕截图。 看起来像无害的自我XSS,这就是为什么我要分享它。

Source: https://habr.com/ru/post/zh-CN414495/


All Articles