在Trello's Taco那充满bug的页面上通过url参数显示你自己的名字详解

在这个充斥着各种bug的链接上—taco-spolsky.github.io。只通过url参数来实现点击按钮显示你自己的名字。
的详解。。。以上。(怎么话都说不通顺了)

背景介绍

这个有趣的玩意儿是我去年离职后找前端面试题的时候在知乎19841848上看到邹润阳提出的。链接就不放了。 直接说Trello的原文吧。

Trello是在某一年。我知道的是2014年国内大火的清单管理系统。2015年未知时间的时候。在一段招聘网页Trello ECMAScript Developer(NYC) Job Opening上有这么一段话。大致意思就是。

通过url传参的形式。让taco-spolsky.github.io点击按钮显示的是你的名字

当时本以为一道很普通的题目。艰难的做完了之后长松了一口气。昨天发给了我之前的一个同事。一个努力想做前端(全栈)的.net工程师。想着这么有趣的东西。那就写下来吧。如果你有兴趣。可以自己先尝试一下。下面来跟着我一起。走进这个据说是Trello吉祥物制作的页面。(注:请不要产生任何这个页面写的真烂这种类似的想法)

页面分析

执行顺序

打开界面。很普通。只有一个按钮。点击一下。显示出了’Taco Spolsky’字样。我们要做的就是要把这个不知道什么意思的两个单词。换成任意我们指定的内容。

查看源代码。写的比较混乱。没关系。咱们按照这堆代码的思路先梳理一下代码结构。

① levelMap = { /* code */ }; //line 7-11
② getSetting = function(){ /* code */ }; //line 13-26
③ validate = function(){ /* code */ }; //line 28-40
④ checksum = 'NOT APPROVED';  //line 42while( /* code */ ){ /* code */ }; //line 45-55
⑥ level = getSetting( /* code */ );  //line 57if( /* code */ ){ /* code */ }; //line 58-60
⑧ CandidateChooser = { /* code */ }; //line 62-88window.addEventListener( /* code */ ); //line 90

点击事件

既然是点击按钮触发事件来显示名字。那么我们先来看点击事件的⑨和⑧。

CandidateChooser = {
  name: "Taco Spolsky",
  recommend: function(e) {
      if (e.target.id != 'choose') {
          return;
      }
      random = Math.random();
      if (random < levelMap[level]) {
          this.name = "Taco Spolsky";
      } else if (random > levelMap[level]) {
          this.name = "Taco Spolsky";
      } else if (random == levelMap[level]) {
          this.name = "Taco Spolsky";
      }
      if (this.name != "Taco Spolsky") {
          if (!validate(this.name, checksum)) {
              this.name = "Taco Spolsky";
          }
      }
      document.getElementById('candidate').textContent = this.name;
  }
}
window.addEventListener('click', CandidateChooser.recommend);

按钮’#choose’在window对象上绑定了代理事件CandidateChooser.recommend。根据this指向性原则。函数CandidateChooser.recommend内this皆指的是全局window对象。实际上等于最后输出的是window.name。(如果不能理解为什么this指的是window。我简单以我的理解说一下。函数的this指向的是调用该函数的对象。上面虽然看起来是通过CandidateChooser调用的。但因为这是处于事件绑定的过程中。this指向的就是被绑定对象的对象。即window。如果绑定在document上。则this指的就是document)。其中有很多判断条件来设置this.name值为默认字符串。因此我们需要将这些条件全部否决掉才可以避免window.name被设置为默认。

先来看random部分。

random = Math.random();
if (random < levelMap[level]) {
    this.name = "Taco Spolsky";
} else if (random > levelMap[level]) {
    this.name = "Taco Spolsky";
} else if (random == levelMap[level]) {
    this.name = "Taco Spolsky";
}

无论random大于等于或者小于levelMap[level]。结果都是不对的。这岂不是没办法了?不对。if语句在进行判断的时候存在很多隐形转换。random必然是数字。那levelMap[level]也会被转换为数字进行比较。那么只要levelMap[level]不能转成数字(会转为NaN)就可以了。不能转换成数字的类型有

字符串:不以数字、+数字、-数字、.数字开头的字符串
数 组:长度大于1的数组、长度等于1且值的格式不能转换为数字的数组(比如1 == ["01"]结果为ture)
对 象:任意
函 数:只要不自执行即任意
undefined 或者 NaN

levelMap[level]的值自然就是上面几种情况。这个我们待会儿再说。再看下面validate部分。

if (!validate(this.name, checksum)) {
    this.name = "Taco Spolsky";
}

可得if的条件括号内必须为假才行。那validate函数必须返回真。

分析完之后。我们再回过头逐个分析。

levelMap[level]项

levelMap是一个开始就被定义好的对象。

levelMap = {
    'easy': 1,
    'medium': .1,
    'hard': .001
}

并且没有再任何地方修改过。那重点就在level这个全局变量上。

根据①可知。首先level不能等于’easy/medium/hard’。level明显被赋值的地方是在第⑥步和第②步以及第⑦步:

getSetting = function(key, settings) {
    settings = settings.split("&");
    for (i = 0; i < settings.length; i++) {
        setting = settings[i];
        if (setting.length <= 'level=medium'.length) {
            if (setting.indexOf(key)) {
                return setting.substr(setting.indexOf(key) + key.length + 1);
            }
        }
    }
    return 'hard';
}
level = getSetting('level', window.location.search || '?level=hard');
if (!levelMap[level]) {
    level = 'medium';
}

先看函数getSetting。参数key是固定的level。settings是location.search或者默认的?level=hard。来看看函数做了那些操作。

settings = settings.split("&"); //将settings以"&"符号分割为数组。一般情况下为["?key=value","key=value"...]
for (i = 0; i < settings.length; i++) {
    setting = settings[i];
    if (setting.length <= 'level=medium'.length) { // 该字段长度小于等于字符串的长度。即小于等于12。
        if (setting.indexOf(key)) { //该字段中key。也就是"level"出现的位置转换为bool值。注意。这里不是setting是否包含key的意思。
            return setting.substr(setting.indexOf(key) + key.length + 1); //截取setting。从参数长度直到setting最后一位。
        }
    }
}
return 'hard'; //如果settings分割出来的数组长度为0。则默认返回hard

首先。函数参数settings被分割成的数组长度不能为0。当然也不可能为0。因为有默认值嘛。
其次。这个数组的字段中必须有一个字符串长度小于等于12。且不能以字符串’level’开头。这里在注意。’indexOf’如果找不到’key’。返回-1的话。是不会被转换为’false’的。!!-1 === true
如果这不满足上面两个条件。那就会默认返回’hard’。OK。我们继续来看给level变量赋值之后的第⑦步。也是可能会改变变量’level’的最后一步。

if (!levelMap[level]) {
    level = 'medium';
}

这。。。如果level不在levelMap对象之上。则会被修改为’medium’。可前面明明说了level值不能等于’easy/medium/hard’的呀。这不是无解了吗?

当然不是。关键词。原型继承。即使levelMap是通过对象字面量创建的。原型依然是继承于’Object’。我们可以在console里打印出来看一下。

levelMap原型

皆为函数。符合我们关于点击事件内random比较条件的分析。因此我们要从Object中挑选一个来作为最后变量level的值。挑选哪个呢?我们再来看函数内循环的那一段。可以得知字符串长度必须是小于等于12。而且还得去除掉key的长度5。剩余的长度是7。而Object中只有’valueOf’符合。也就是说。在location.href被分割成的数组之中。其中有一项必须是五个随意字符+’valueOf’。既如此。location.search的格式例如’?key=valueOf’。如若不是数组的第一个参数。那在Ta之前的所有数组参数必须不能满足之前说的那两个条件。比如可以是’?123456789876&12345valueOf’

levelMap[level]项研究结束。我们再来看validate函数和checksum

validate和checksum

我们来分析第④、⑤、③步。第④步赋值略过。

while (location.hash.length > 1 && !name) {
    entries = location.hash.split('|');
    for (i = 0; i < entries.length; i++) {
        parts = entries[i].split('=');
        name = decodeURIComponent(parts[0]);
        value = decodeURIComponent(parts[1]);
        if (name == 'checksum') {
            checksum = value;
        }
    }
}
validate = function(name, checksum) {
    var string = name + "approved by Taco";
    var testsum = 123;
    for (var i = 0; i < string.length; i++) {
        testsum = testsum * 13 + string.charCodeAt(i);
        while (testsum > 10000000) {
            testsum -= 1000000;
        }
    }
    return testsum == checksum;
}

不具体分析了。忽然发现写的太长了。注意几点。先说’while’循环。

1、存在hash且name未定义(或定义为可转换为false的值)
2、当你只是刷新页面的时候。只要不更改origin/pathname/search。window.name是不会变的。调试的时候一定要注意。原理嘛。我翻遍了也没找到。倒是找到一堆说利用这个来跨域的。可能有点儿关系吧。
3、hash值是以’|’分割的。
4、被分割成的数组中有一个字段位于’=’前面的是’checksum’.后面的一个checksum的值。有一个字段位于’=’前面的值就是你要修改的你的名字。之后for循环结束就会跳出while循环。然后就再也没有机会修改window.name

validate函数需要说的细致一点儿。首先。由一开始在点击事件里的的判断可知。函数必须返回true才可以。

validate = function(name, checksum) {
    var string = name + "approved by Taco";
    var testsum = 123;
    for (var i = 0; i < string.length; i++) {
        testsum = testsum * 13 + string.charCodeAt(i);
        while (testsum > 10000000) {
            testsum -= 1000000;
        }
    }
    return testsum == checksum;
}

过程是将上一步获得值的name与’approved by Taco’拼接。通过一段算法。来得出一个数值。使这个数值等于通过上一步获得的checksum的值。其中的算法利用了’charCodeAt’就是每一位的Unicode编码来计算。我们不需要去接入具体的算法。我们只要把我们的名字给代入进去让已经写好的算法帮我们算出来即可。如下。比如说我的’name’是’nostar’

var string = "nostar" + "approved by Taco";
var testsum = 123;
for (var i = 0; i < string.length; i++) {
    testsum = testsum * 13 + string.charCodeAt(i);
    while (testsum > 10000000) {
        testsum -= 1000000;
    }
}
console.log(testsum) // 9887521

OK。可以得知。当我的name值为’nostar’的时候。checksum必须是9887521才可以。如果你要用自己的名字。则也需要自己代入这个算法算出来。我没有测试中文和特殊符号。不知道情况会如何。估计会报错吧。。。

综合上述分析

假如我需要点击按钮显示我的名字为’nostar’。那么。。。

1、 location.search必须存在。且被’&’分割出的数组中有一项必须是小于等于12位且不能以’level’开头并且后7位必须是’valueOf’。按照最小程度模拟就是’?key=valueOf’

2、 location.hash必须存在。且被’|’分割出来的数组中。之前有一项必须是’checksum=9887521’。之后有一项必须是’nostar=…’或者直接就是’nostar’。按照最小程度模拟就是’#|checksum=9887521|nostar’

因此。location.href全写便是

"https://taco-spolsky.github.io/?key=valueOf#|checksum=9887521|nostar"

测试一下。嗯。很正确。再刷新一下。咦?又不正确了。还是那个问题。注意不修改’origin/pathname/search’刷新的时候window.name不会变的。’while’循环也不会执行。因此加个断点在开始的时候清空window.name即可。这算是一个小坑。测试的小坑。

结语

题目是很有意思的。比那些让你写这个函数、问那个方法要有趣的多。而且考察很多基础知识。也很容易让人混淆。不过嘛。道高一尺。佛高一丈。再遇到这种题目的时候。我教你一个方法。就是全部打上断点逐步调试。什么全局变量局部变量不清不楚了、变量被修改的顺序了、this的指向问题了就都可以很好的看出来了。

最后。我忽然又想起了也是去年同时期看到的阿里的一道面试题。

在不使用loop循环的情况下。输出一个[1,2,3,…,98,99,100]的数组。

嘛。嘛。嘛。轻轻地捧着你的脸。为你把眼泪擦干。这颗心永远属于你。告诉我不再孤单。。。