【译】Facebook 第六感 · Alexandre Kirszenberg

1825次阅读  |  发布于5年以前

原文:http://www.zcfy.cc/article/396

我每天都使用 Facebook,也许太着迷了,以至于我总想潜入他们内部。我说的"潜入"指的是研究他们用来实现许多用户界面和交互的 JavaScript。

几个月之前,我开始思考在聊天中,当对方正在打字时,出现的那个正在输入的指示符。这只是一个 UI 的小细节,但是能透露出你的交谈者许多信息。如果正在输入的指示符反复出现和消失,说明对方在犹豫什么。如果正在输入的指示符是持续出现的,说明他们与你的谈话可能比较轻松。有时候看到它们出现然后又消失,对方却最终没有说话,没有比这更折磨人的了。

The typing indicator

这动画看起来像是张自以为是的脸,我觉得它在嘲弄我!

首先得弄明白显示指示符所需要的数据是否都被发送给了所有交谈者,不论他们是否打开了消息窗口。我的第一步是打开调试工具的 Network 选项卡,开始寻找当有人正在对我输入信息时的活动请求。

The typ event in the response from the /pull request

最终我找到了!typ 事件被通过全局的 Facebook 长轮询 /pull 请求发送。一些小的实验可以证实实际上每个交谈都会发送这个事件,甚至包括那些没有被我开着的聊天窗口,和从来没和我聊过天的人。

尽管我们可以止步于此,但是我觉得我有义务用这个发现来做一些东西。

因此我向你介绍我的"恐怖"发明:Facebook 第六感。一个可以在有人开始和结束对你输入消息时给你警告信息的 Chrome 插件。

The typ event in the response from the /pull request

这篇文章的剩余内容会详细解释我如何使用 Facebook 自己的私有 API 来构建出这个插件的。

反压缩 JavaScript

如今,大多数应用倾向于通过工具压缩线上的 JavaScript,常用的压缩工具例如 UglifyJS 或者 Google Closure Compiler 就是用来做这个的。然而压缩后的代码不是字节码,这些工具在不改变应用程序预期行为的基础上能够有如此高的压缩比让人印象深刻。压缩之后用户能够在较短的时间内加载页面,公司也能节省带宽成本。双赢不是吗?

除了我们这件事。

压缩后源代码的可读性降低了很多。局部变量名字被替换成单个字母,失去了它们在程序中的实际含义,一些非关键的冗余语法被去除了或者替换成了短一些的替代方案。空白符也被取出了,语法结构变得难懂。

A look into the Facebook source

我们迷失在数以万字符计的单行混淆的代码中

别慌!我们可以通过增加一些换行和缩进来恢复一些条理。事实上,Chrome 开发者工具甚至为我们提供了这个功能。

Prettified code

现在至少看起来有条理一些

好了,我们可以开始阅读一些代码了!

模块

JavaScript 依然没有很好地支持模块化。一个标准已经被提出,但还没有一个浏览器实现了它。与此同时,一些临时解决方案被提出,例如 Asynchronous Module Definition 和 CommonJS。

Facebook 自己实现了 AMD 模块管理。模块通过 __d(name, dependencies, factory) 来定义,通过 requirerequireLazy 来加载。

我会告诉你自己在控制台输入一些代码试试看,而 Fackbook 在控制台里面输出了一个非常明显的警告让你不要拷贝和粘贴不确定的代码到控制台里。这是一个好主意,别动控制台,好吗?

Facebook self-XSS warning message

这个 Facebook 的家伙一定是个严肃认真的家伙。最好遵守他所说的

我是在开玩笑,操家伙上吧!

> require("React")
    // Object {Children: Object, PropTypes: Object, DOM: Object, version: "16.0.0-alpha"}

是的,Facebook 总是运行 React 的主干版本。这非常大胆,但是很酷。

你可以在开发者工具的 Sources 选项卡中搜索 __d 找到所有可以被加载的模块。使用 Alt+Shift+F 来查找所有的源代码文件。如果你去找了,你会发现模块有一大堆。

Searching for modules

才 3000 个模块?图样图森破!这只是主页面。

所有模块都用 React

如今你可能已经知道 React。这是一个 Facebook 开发的非常酷的库,它能让你使用 JavaScript 语法来声明式定义你的 view 组件。我没打算做 React 的广告,但是如果你还对它很陌生,先去弄懂它。

正如我们所见,Facebook 在他们主要网站使用 React,使用得非常广泛。在 2015 年 10 月,它们已经拥有超过 15,000 个 React 组件了!

另一个关于 React 组件的好消息是,只要代码写得正确,模块之间是有非常明确地分工的。也就是说,它们的依赖是明确的,大部分情况下每个模块只负责做并做好一件事。而这个性质对我们接下来进一步做的事来说十分有用。

追踪游戏

别忘了,我们是来尝试拦截给我们发消息的人的输入通知的。让我们从聊天的消息框开始研究。

The Facebook chat box

我发誓,我有真正的朋友。

还记得关于 React 组件吗?聊天消息框是其中一个组件。有一个非常好的技巧来检查它的细节:使用 React 开发工具。

点击聊天消息框,选择 Inspect,然后到开发者工具的 React 选项卡。搞定!现在你来到 React 的领地了!

Inspecting an element

检查 React。

The React Devtools

看这个,多酷啊!

别迷失在 Facebook React 组件的桃花源里。我们要寻找一个特别的东西:聊天输入指示符,一种最常被发现在聊天室底部的令人难以捉摸的生物。

The ChatTypingIndicator component

就是这个,强大的聊天输入提示符

如果你在界面上找不到,你依然可以在 <ChatTabComposerContainer /><MercuryLastMessageIndicator /> 标签之间找到它。

好,先在我们有个更好的主意了,让我们进一步深入了解它。

在 React 代码库里查找 __d('ChatTyping 得到两个模块,ChatTypingIndicator.react.js, 和 ChatTypingIndicators.react.js,这是我们要找的。注意 .react 前缀表示模块是一个 React 组件。

Searching for the ChatTypingIndicators module through the codebase

如果你找不到 ChatTypingIndicators.react.js 模块,你需要打开一个聊天窗口去再查找一次。一些模块只有当用到它时才会被加载。

剖析 React 组件

React 组件在它的生命周期中会触发一些 hooks。例如,当它在 DOM 中载入之后,componentDidMount 方法会被执行。

这个方法是订阅事件和异步加载数据的首选地方,所以这里是我们首先要看的。

The componentDidMount method

好,看起来是正确的。

以下是代码全貌:

function() {
      var k = c("MercuryThreadInformer").getForFBID(this.props.viewer)
        , l = c("MercuryTypingReceiver").getForFBID(this.props.viewer);
      this._subscriptions = new (c("SubscriptionsHandler"))();
      this._subscriptions.addSubscriptions(
        l.addRetroactiveListener(
          "state-changed",
          this.typingStateChanged
        ),
        k.subscribe(
          "messages-received",
          this.messagesReceived
        )
      );
    },

看到 c('MercuryTypingReceiver') 调用了吗? 这是压缩版的 require('MercuryTypingReceiver')。所以我们在检索一个非常明确的函数 MercuryTypingReceiver,它调用 typingStateChanged 方法,只要它的内部状态有所改变。我们小模块开始看起来很像一个 Flux Store 了。但是我们并不需要真正去弄明白它是如何运作的,这个 addRetroactiveListener() 看起来像是我们要找的。

The typingStateChanged method

窥视 typingStateChanged 方法,我们可以获得 MercuryTypingReceiver 存储在 store 的数据结构。它像是一个字典一样将聊天的消息线程 id 对应到一个 user id 的数组里。我们一会而再回来处理它,让我们先尝试使用这个 MercuryTypingReceiver

MercuryTypingReceiver

使用我们的新朋友 require 函数,我们可以在控制台里面检查 MercuryTypingReceiver。让我们来看一下。

> const MercuryTypingReceiver = require("MercuryTypingReceiver");
    // undefined
    > MercuryTypingReceiver
    // function j(k){/* bunch of gibberish */}

看起来不妙。如我们在前面所见过的,MercuryTypingReceiver 调用一个 getForFBID 静态方法。然而,Chrome 开发者工具不允许我们直接查看一个函数里的属性。为了绕开这个限制,我们需要将它包装成一个对象:

The internals of the MercuryTypingReceiver module

看起来好多了

(新技能 Get,控制台里查看一个函数上的属性,可以用 {function(){}} ---- 译者注)

现在让我们来看一下它的两个静态方法,getgetForFBID

> MercuryTypingReceiver.getForFBID
    // function (i){var j=this._getInstances();if(!j[i])j[i]=new this(i);return j[i];}
    > MercuryTypingReceiver.get
    // function (){return this.getForFBID(c("CurrentUser").getID());}

由此,我们可以断定 MercuryTypingReceiver.getForFBID(fbid) 从一个 FBID 字符串中查找一个 MercuryTypingReceiver 实例,而 MercuryTypingReceiver.get() 是一个通用的函数为当前用户查找 MercuryTypingReceiver 实例。我们可以丢掉 getForFBID() 只要使用 get() 就可以了。

你应该还记得我们的 React 组件调用了 MercuryTypingReceiver 实例上的 addRetroactiveListener(eventName, listener) 方法。它使用起来非常简单:

const inst = MercuryTypingReceiver.get();
    inst.addRetroactiveListener("state-changed", state => {
      console.log(state);
    });

现在,如果你收到了一些输入状态改变的通知,你已经可以在控制台上看到一些状态对象被打印出来了。一个简单的测试方法是自己输入要发送的消息。你可以使用你手机上的 Messenger app 来发送输入事件。

The state objects in the console

好了,我们可以庆幸我们之前猜对了,MercuryTypingReceiver 存储状态是一个字典,它将消息线程 id 对应到所有正在输入字符的 user id。

这是正我们之前寻找的,但是没有办法将消息线程 id 和 thread's name 关联起来,也没有办法将 user id 和 user's full name 关联起来,它真的没有告诉我们很多有用的信息。

MercuryThreads 和 ShortProfiles

在代码中做了更多的尝试之后,我找到了两个非常有用的模块:

我们甚至可以在同一时间批量处理多个线程和多个用户的信息!

我打算再进一步深入关于上面两个 API 的实现细节,因为我想下面的代码本身已经非常能说明问题了。

最后,完整代码

一共 40 多行代码

function getUserId(fbid) {
      return fbid.split(":")[1];
    }

    requireLazy(
      ["MercuryTypingReceiver", "MercuryThreads", "ShortProfiles"],
      (MercuryTypingReceiver, MercuryThreads, ShortProfiles) => {

        MercuryTypingReceiver
          .get()
          .addRetroactiveListener("state-changed", onStateChanged);

        // Called every time a user starts or stops typing in a thread
        function onStateChanged(state) {

          // State is a dictionary that maps thread ids to the list of the
          // currently typing users ids"
          const threadIds = Object.keys(state);

          // Walk through all threads in order to retrieve a list of all
          // user ids
          const userIds = threadIds.reduce(
            (res, threadId) => res.concat(state[threadId].map(getUserId)),
            []
          );

          MercuryThreads.get().getMultiThreadMeta(threadIds, threads => {
            ShortProfiles.getMulti(userIds, users => {
              // Now that we"ve retrieved all the information we need
              // about the threads and the users, we send it to the
              // Chrome application to process and display it to the user.
              window.postMessage({
                type: "update",
                threads,
                users,
                state,
              }, "*");
            });
          });

        }
      }

    );

结束了

这篇文章我最想让大家明白的是 hook 一个有着良好结构的现代 Web 应用是多么容易的事情。JavaScript 模块组合,React 和 Flux 使我们能够将我们的逻辑代码注入到 UI 和应用的数据流中去。

你可以在 Github 上找到 完整源代码.

这是全部!希望你阅读愉快。如果有任何问题,你可以在 twitter 上联系我 @Morhaus。

英文原文:http://kirszenberg.com/facebook-sixth-sense

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8