Chromium扩展(Extension)机制简要介绍和学习计划

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

Chromium提供了一种Extension机制,用来增强浏览器功能。我们可以将Extension看作是一种运行在Chromium中的应用。这种应用的开发语言是JavaScript,并且UI通过HTML描述。通过使用Chromium提供的API,Extension可以访问网络,修改浏览器行为,以及操作网页的内容等。本文接下来对Chromium的Extension机制进行简要介绍,以及制定学习计划。

在Chrome(基于Chromium实现,以下我们将交替使用Chrome和Chromium)的地址栏中输入"chrome://extensions",就可以看到当前安装的所有Extension,如图1所示:

图1 Chrome中的Extension列表

图1显示了两个Extension。一个是基于Browser Action实现的,另一个是基于Page Action实现的。它们在地址栏的右边分别对应有一个Button。点击这两个Button,可以弹出一个窗口。这一点后面我们会看到。

接下来,我们就通过上述两个Extension例子,对Chromium的Extension机制进行介绍。

每一个Extension都包含有一个清单文件manifest.json,类似于Android应用程序的AndroidManifest.xml文件。前者是json格式的,后者是xml格式的。清单文件描述了Extension的内容。以图1所示的Browser action example为例,它的manifest.json如下所示:

{
      "manifest_version": 2,

      "name": "Browser action example",
      "description": "This extension show a image and changes a web page's background",
      "version": "1.0",

      "browser_action": {
        "default_icon": "icon.png",
        "default_popup": "popup.html"
      },

      "content_scripts": [
        {
          "matches": ["http://*/*"],
          "js": ["content.js"],
          "run_at": "document_start",
          "all_frames": true
        }
      ]
    }

前面几行是一些描述性信息。后面的browser_action和content_scripts指定了一个Browser Action和一个Content Script。

Browser Action对在浏览器中加载的所有网页都生效。后面我们可以看到,Extension还有一种Page Action,它针对特定的网页生效。一个Extension最多可以有一个Browser Action或者Page Action。不管是Browser Action,还是Page Action,都可以指定一个icon文件和一个popup文件。前者是一个图片,后者是一个html文件。指定的icon将会以Button的形式展现在地址栏的右边。当这个Button被点击的时候,就会弹出一个窗口,窗口会加载在清单文件中指定的popup.html文件。注意,这里指定的文件路径都是相对路径,相对Extension的根目录的。

上述popup.html的内容如下所示:

<html>
      <head>
        <title>Getting Started Extension's Popup</title>
        <style>
          body {
            font-family: "Segoe UI", "Lucida Grande", Tahoma, sans-serif;
            font-size: 100%;
          }
          #status {
            /* avoid an excessively wide status text */
            white-space: pre;
            text-overflow: ellipsis;
            overflow: hidden;
            max-width: 400px;
          }
        </style>

        <!--
          - JavaScript and HTML must be in separate files: see our Content Security
          - Policy documentation[1] for details and explanation.
          -
          - [1]: https://developer.chrome.com/extensions/contentSecurityPolicy
         -->
        <script src="popup.js"></script>
      </head>
      <body>
        <div id="status"></div>
        <img id="image-result" hidden>
      </body>
    </html>

这个html文件的内容很简单,由以下内容组成:

1. 一个popup.js脚本

2. 一个div标签

3. 一个img标签

其中,div标签用来显示状态信息,img标签用来显示图片。它们的内容都是popup.js指定的,如下所示:

function getImageUrl(callback, errorCallback) {
      callback("https://images-cn-8.ssl-images-amazon.com/images/I/61vnPRDVoeL.jpg", 200, 250);
    }

    function renderStatus(statusText) {
      document.getElementById('status').textContent = statusText;
    }

    document.addEventListener('DOMContentLoaded', function() {
        getImageUrl(function(imageUrl, width, height) {
          var imageResult = document.getElementById('image-result');
          imageResult.width = width;
          imageResult.height = height;
          imageResult.src = imageUrl;
          imageResult.hidden = false;

        }, function(errorMessage) {
          renderStatus('Cannot display image. ' + errorMessage);
        });
    });

这个popup.js所做的事情非常简单,就是为popup.html中的img标签指定一个src。安装了这个Entension之后,就可以在浏览器地址栏的右边出现一个Button。点击这个Button,就可以弹出一个窗口,它的内容如图2所示:

图2 Browser Action的popup.html

Browser action example的清单文件还指定了一个Content Script,即content.js。这个content.js通过matches字段指定对任何网页生效。这里所说的生效,是指将content.js注入到网页中去执行的。

上述content.js的内容如下所示:

document.body.style.backgroundColor="red"

它做了一件非常简单的事情,就是将网页的背景设置为红色。这件事情虽然非常简单,但是它告诉了我们,Extension通过Content Script可以操作在Chromium中加载的任何一个网页的内容!

我们再来看另一个Page action example。它的清单文件如下所示:

{
      "manifest_version": 2,

      "name": "Page action example",
      "description": "This extension show a image and changes a web page's background",
      "version": "1.0",

      "background": {
        "scripts": ["background.js"]
      },

      "page_action": {
        "default_icon": "icon.png", 
        "default_popup": "popup.html" 
      },

      "permissions": [
         "tabs"
       ],

      "content_scripts": [
        {
          "matches": ["https://fast.com/"],
          "js": ["content.js"],
          "run_at": "document_start",
          "all_frames": true
        }
      ]
    }

这个清单文件没有指定Browser Action,但是指定了Page Action,以及Content Script和Background。Content Script和Background描述的都是一个JavaScript脚本,前者称为content.js,后者称为background.js。

Page Action与Browser Action类似,它也对应有一个icon和一个popup。不过,Page Action是有状态的,分为显示和隐藏两种。为显示状态时,它在地址栏右边的Button变亮,并且可以点击。为隐藏状态时,它在地址栏右边的Button变灰,并且不可以点击。

我们可以通过Chromium提供的Page Action API(chrome.pageAction.show和chrome.pageAction.hide)显示或者隐藏Page Action。一般就是根据当前打开的网页决定要显示还是隐藏一个Page Action。这个判断逻辑一般就是实现在background.js中,如下所示:

chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) { 
      if (tab.url.indexOf("fast.com") >= 0) {
        chrome.pageAction.show(tabId);  
      }

      var views = chrome.extension.getViews({type: "tab"});
      if (views.length > 0) {
        console.log(views[0].whoiam);
      } else {
        console.log("No tab");
      }
    });  

    chrome.runtime.onMessage.addListener(
      function(request, sender, sendResponse) {
        sendResponse({counter: request.counter + 1 }); 
      }
    );

    var whoiam = "background.html"

这个background.js通过chrome.tabs.onUpdated.addListener这个API指定了一个Listener。每当我们切换到浏览器中的Tab时,与这个Listener绑定的函数就会被调用。这个函数是一个匿名函数,它做了两件事情。

第一件事情是判断当前激活的Tab打开的网页的URL是否包含了"fast.com"关键字。如果包含了,那么就通过chrome.pageAction.show这个API将清单文件中指定的Page Action显示出来。

第二件事情是调用chrome.extension.getViews这个API检查当前的Extension是否有页面在浏览器的Tab中打开。如果有打开,那么就会获得这些页面的window对象。一旦获得了一个页面window对象,我们就可以访问它的成员。例如,定义在页面中的函数和变量。这实际上是提供了一种方式,使得Extension的不同页面可以相互通信。这里我们就是访问了一个whoiam变量,并且将它的内容打印出来。

关于Extension页面,及其通信方式,我们接下来还会进一步解释。

上述background.js还通过chrome.runtime.onMessage.addListener这个API定义了一个Listener以及一个whoiam变量。其中,Listener用来接收其它页面给它发送的消息,变量whoiam可以被其它页面直接访问。

当Page Action通过chrome.pageAction.show这个API被设置为显示状态时,我们就可以点击它在地址栏右边的按钮,弹出一个窗口。这个窗口在清单文件中指定加载的网页为popup.html,它的内容如下所示:

<html>
      <head>
        <title>Getting Started Extension's Popup</title>
        <style>
          body {
            font-family: "Segoe UI", "Lucida Grande", Tahoma, sans-serif;
            font-size: 100%;
          }
          #status {
            /* avoid an excessively wide status text */
            white-space: pre;
            text-overflow: ellipsis;
            overflow: hidden;
            max-width: 400px;
          }
        </style>

        <!--
          - JavaScript and HTML must be in separate files: see our Content Security
          - Policy documentation[1] for details and explanation.
          -
          - [1]: https://developer.chrome.com/extensions/contentSecurityPolicy
         -->
        <script src="popup.js"></script>
      </head>
      <body>
        <table align='center'>  
          <tr>  
            <td><button id="testRequest">send 0 to tab page</button></td>  
            <td id="resultsRequest"><font color="gray">response: null</font></td>  
          </tr>   
        </table>  
        <div id="status"></div>
        <img id="image-result" hidden>
      </body>
    </html>

与前面Browser action example中的popup.html一样,这个popup.html也包含了一个popup.js,一个div标签和一个img标签。不过,这个popup.html还多了一个table标签。这个table包含了一行两列。其中一列用来显示一个Button,另一列用来显示文本。

上述popup.html显示出来的效果如图3所示:

图3 Page Action的popup.html

这个popup.html显示的图片是通过它包含的popup.js指定的,如下所示:

function getImageUrl(callback, errorCallback) {
      callback("http://avatar.csdn.net/5/6/E/1_luoshengyang.jpg", 200, 200);
    }

    function renderStatus(statusText) {
      document.getElementById('status').textContent = statusText;
    }

    var counter = 0;

    function testRequest() {  
      chrome.tabs.getSelected(null, function(tab) {   
        chrome.tabs.sendRequest(tab.id, {counter: counter}, function handler(response) {  
          counter = response.counter;
          document.querySelector('#resultsRequest').innerHTML = "<font color='gray'> response: " + counter + "</font>";
          document.querySelector('#testRequest').innerText = "send " + (counter -1) + " to tab page";
        });  
      });  
    }  

    document.addEventListener('DOMContentLoaded', function() {
      getImageUrl(function(imageUrl, width, height) {
        var imageResult = document.getElementById('image-result');
        imageResult.width = width;
        imageResult.height = height;
        imageResult.src = imageUrl;
        imageResult.hidden = false;

        console.log(chrome.extension.getBackgroundPage().whoiam);
      }, function(errorMessage) {
        renderStatus('Cannot display image. ' + errorMessage);
      });

      document.querySelector('#testRequest').addEventListener(  
          'click', testRequest);  
    });

    var whoiam = "popup.html"

这个popup.js是在页面的文档加载完成时指定显示的图片的。同时,它还会在页面的文档加载完成时,做另外两件事情。

第一件事情是调用chrome.extension.getBackgroundPage这个API获得当前Extension的Background页面的window对象,并且通过这个window对象访问定义在Background页面中的whoiam变量。前面我们在background.js定义了一个whoiam变量,因此这里就可以对它进行访问。

第二件事情为popup.html页面中显示为"send to tab page"的Button指定一个Listener。这个Listener用来监听Button的Click事件。一旦监听到Click事件发生,函数testRequest就会被调用。

函数testRequest首先通过chrome.tabs.getSelected这个API获得当前激活的Tab,接着又通过chrome.tabs.sendRequest这个API向获得的当前激活的Tab发送一个消息。消息是json格式的,传递一个由变量counter描述的计数给接收者。接收者接收到这个消息之后, 会将它封装的计数取出来,并且增加1,然后再将结果返回来。返回来的结果保存在变量counter的同时,也会显示在popup.html页面中id为resultsRequests的td标签中。

这样,通过不断地点击popup.html页面中显示为"send to tab page"的Button,就可以不断地与当前激活的Tab通信。后面我们可以看到,实际上是与在Tab中加载的Content Script通信的。

从popup.js的内容还可以看到,它定义了一个变量whoiam。这个变量可以被其它Extension页面直接访问。

前面提到,Page action example的清单文件还指定了一个Content Script,即content.js。这个content.js通过matches字段指定仅对URL为"https://fast.com/"的页面生效,也就是它只会注入到URL为"https://fast.com/"的页面中去。注入的内容如下所示:

document.body.innerHTML = "<table align='center'>\
                                 <tr>\
                                   <td><button id='testRequest'>send 0 to background page</button></td>\
                                   <td id='resultsRequest'>response: null</td>\
                                   </tr>\
                               </table>" +
                               document.body.innerHTML;

    chrome.extension.onRequest.addListener(  
      function(request, sender, sendResponse) {  
        sendResponse({counter: request.counter + 1 });  
      }
    );

    var counter = 0;

    function testRequest() {  
      chrome.runtime.sendMessage({counter: counter}, function(response) {
        counter = response.counter;
        document.querySelector('#resultsRequest').innerText = "response: " + counter;
        document.querySelector('#testRequest').innerText = "send " + (counter -1) + " to background page";
      });
    }

    document.querySelector('#testRequest').addEventListener(  
       'click', testRequest);

它注入了一个table。这个table包含一行两列。其中一个列包含了一个显示为"send 0 to background page"的Button,另外一个列用来显示文本。

注入的效果如图4所示:

图4 Content Script

当我们点击显示为"send 0 to background page"的Button时,定义在content.js中的函数testRequest就会被调用。函数testRequest用来向当前的Extension的Background页面发送消息。这个消息传递一个由变量counter描述的计数给Background页面。

Background页面在background.js中通过chrome.runtime.onMessage.addListener这个API定义了一个Listener。这个Listener用来接收上述由content.js发送过来的消息。接收到该消息后,background.js会将其封装的计数取出,并且增加1,然后将结果返回给content.js显示。

此外,content.js还通过chrome.extension.onRequest.addListener这个API定义了一个Listener。这个Listener用来监听其它页面发送过来的消息。也就是前面提到的从popup.html中发送过来的消息。

分析到这里,我们小结一下,Page action example这个Extension所包含的内容:

1. popup.html:显示在一个弹出窗口中。

2. background.html: 这个页面是由Chromium根据background.js生成出来的,没有显示在窗口中,因此称为Background页面。

3. content.js:一个注入在宿主页面中的JavaScript。

其中,前两个属于页面,后面一个属于脚本。事实上,一个Extension可以同时拥有若干个页面。这些页面分为五种类型为:background、popup、tab、infobar、notification。它们分别代表在不同窗口打开的页面。其中,前面两种我们已经描述过了,后面三种也比较容易理解:

1. tab:像正常网页一样在浏览器的Tab中打开的页面。

2. infobar: 在浏览器顶部信息栏显示的信息页面。

3. notification:在浏览器底部显示的通知页面。

每一个页面都有一个URL。URL的规范为:chrome-extension://[extension-id]/path。其中,协议部分为chrome-extension,extension-id是Chromium为Extension分配的ID,path是页面的路径。

从图1可以看到,Chrome为Page action example分配的Extension ID为"abcemahgedfccgcmlkaeiabpjjjhhmoc"。按照上述规范,图3显示的popup页面的URL就为"chrome-extension://abcemahgedfccgcmlkaeiabpjjjhhmoc/popup.html"。这个URL可以直接输入到Chrome的地址栏中去,使得popup.html也可以显示在一个Tab中。

Page action example还包含了另外一个tab.html文件,它的内容如下所示:

<html>
      <head>
        <title>Getting Started Extension's Tab</title>
      </head>
      <body>
        <table align='center' height="100%">
          <tr>
            <td><img src="sample.png" /></td>
          </tr>
        </table>
      </body>
    </html>

这是一个简单的html页来,用来显示打包在Extension中的一个图片sample.png。

我们可以在浏览器的地址栏中输入"chrome-extension://abcemahgedfccgcmlkaeiabpjjjhhmoc/tab.html"访问这个页面,效果如图5所示:

图5 Open extension page in tab

这样,我们就可以归纳出一个Extension是由Extension Page和Content Script构成的。

Extension Page与普通的网页一样,具有一个URL。常规的Extension Page有popup.html、background.html。其中,popup.html显示在一个弹出窗口中,background.html运行在后台,没有显示在窗口中。给出一个Extension Page的URL,我们也可以在浏览器的Tab中打开它。

Content Script是一个JavaScript脚本,它会注入到宿主网页去执行,从页可以访问它的DOM Tree。这里说的宿主网页,就是在浏览器中加载的网页。这些网页是由网站提供的,不属于Extension的一部分。

Extension Page之间,以及Extension Page与Content Script之间,是可以相互通信的。正是因为它们可以相互通信,才能形成一个整体,共同去完成一个Extension的功能。其中,background.js所在的background.html扮演着一个中心角色。它在Extension加载的时候就会默默加载,并且一直运行在后台。其它的Extension Page或者Content Script都是按需加载的。因此,我们就可以将Extension的状态维护在background.html中,其它的Extension Page或者Content Script需要的时候,可以与它进行通信。

那么,Extension Page之间,以及Extension Page与Content Script之间,是如何通信的呢?我们通过图6说明,如下所示:

图6 Extension Page与Content Script之间的通信方式

同一个Extension的Extension Page都是运行在同一个进程中的。这个进程称为Extension进程,这实际上也是一个Render进程。在同一个进程打开的网页可以在JavaScript中直接获得对方的window对象。有了一个网页的window对象,就可以调它里面定义的函数或者变量,相当于就是可以与它进行通信。

Chromium的Extension机制提供了两个API:chrome.extension.getViews和chrome.extension.getBackgroundPage。其中,通过前者可以获得Background Page之外的Extension Page的window对象,而通过后者可以获得的Background Page的window对象。chrome.extension.getViews这个API在调用的时候,还可以指定一个type参数,用来指定要获取哪一种类型的Extension Page的window对象。如果没有指定,则获取Background Page之外的所有Extension Page的window对象。

Extension的Content Script由于是注入在其它网页中运行,因此它们不能与Extension Page进行直接通信,而是要进行跨进程通信。又由于Content Script和Extension Page是相互不知道对方的,因此它们在进行跨进程通信的时候,需要有一个桥梁。这个桥梁就是Browser进程。

Chromium的Extension机制提供了两个API:chrome.tabs.sendRequest和chome.extension.onRequest,用来从Extension Page向Content Script发送消息。同样,Chromium的Extension机制也为从Content Script向Extension Page发送消息提供了两个API:chrome.runtime.sendMessage和chrome.runtime.onMessage。它们的实现原理是一样的。当chrome.tabs.sendRequest或者chrome.runtime.sendMessage被调用的时候,它们会向Browser进程发送一个类型为ExtensionHostMsg_PostMessage的IPC消息。Browser进程接收到这个消息之后,又会向目标进程发送一个类型为ExtensionMsg_DeliverMessage的IPC消息。目标进程接收到这个消息之后,再通过JavaScript引擎分发给chome.extension.onRequest或者chrome.runtime.onMessage处理。

了解了Extension Page之间,以及Extension Page与Content Script之间的通信机制之后,我们再来看Extension Page和Content Script的加载过程。

Chromium的Browser进程在启动的时候,会创建一个Startup Task。这个Startup Task会初始化一个Extension Service,如图7所示:

图7 Extension加载过程

Extension Service在初始化的过程中,会通过一个Installed Loader加载当前用户安装的所有设置为Enabled的Extension。这些Extension形成一个列表,保存在一个Extension Registry中。以后通过这个Extension Registry,就可以获得当前启用的所有Extension的信息。

如果一个Extension指定了Background Page,那么Browser进程在初始化好浏览器窗口之后,还会自动加载它指定的Background Page,如图8所示:

图8 Background Page和Popup Page的加载过程

Browser进程初始化好浏览器窗口之后,会发送一个OnBrowserWindowReady通知。这个通知会触发Browser进程创建一个ExtensionHost对象。这个ExtensionHost对象又会通过WebContents类的静态成员函数Create加载指定的Background Page。WebContents类是Chromium的Content层向外提供的一个API。通过这个API,就可以使用Chromium来加载一个指定的网页了。

注意,Background Page会加载在一个Extension进程中。如果这个Extension进程还没有创建,那么WebContents类的静态成员函数Create会先创建它。以后Browser进程如果要与这个Background Page进行通信,那么就会通过上述创建的ExtensionHost对象进行。从这里我们也可以看到,每一个Background Page在Browser进程中都有一个对应的ExtensionHost对象,就类似于普通的网页在Browser进程中都有一个对应的RenderProcessHostImpl对象一样。这一点可以参考前面Chromium多进程架构简要介绍和学习计划这个系列的文章。这是很好理解的,因为Extension进程本质上也是一个Render进程。

其它类型的Extension Page,它们则是按需加载的。例如,对于Popup Page来说,当用户点击了它在地址栏右边对应的Button的时候,Browser进程才会加载它们。它们的加载过程与上述的Background Page是类似的,即先创建一个ExtensionHost对象,然后再通过WebContents类的静态成员函数Create进行加载。

最后,我们再来看Content Script的加载过程,如图9所示:

图9 Content Script的加载过程

Content Script的加载过程由三个流程组成。

第一个流程发生在Extension加载过程中,也就是图7所示的Extension加载流程。Browser进程在启动的时候,会创建一个UserScriptMaster对象,用来监听所有的Extension的加载事件。如果当前被加载的Extension指定了Content Script,那么指定的Content Script的内容就会保存在上述UserScriptMaster对象中。

第二个流程发生在Content Script的宿主网页所对应的Render进程的启动过程中。当这个Render进程启动完成时,Browser进程会获得一个OnProcessLaunched通知。这个通知直接分发给代表该Render进程的一个RenderProcessHostImpl对象处理。这个RenderProcessHostImpl对象首先会到上述的UserScriptMaster对象中收集要在宿主网页中加载的Content Script,然后再通过一个类型为ExtensionMsg_UpdateUserScript的IPC消息将这些Content Script发送给宿主网页所在的Render进程。这个Render进程会通过一个Dispatcher对象接收该IPC消息,并且将发送过来的Content Script保存在一个UserScriptSlave对象中。

第三个流程发生在Content Script的宿主网页的加载过程中。Content Script可以在Extension的清单文件中指定加载时机。有三个时机可以指定:document_start、document_end和document_idle,分别表示在宿主网页的Document对象开始创建、结束创建以及空闲时加载。接下来,我们假设Content Script指定了在document_start时加载。

从前面Chromium网页DOM Tree创建过程分析一文可以知道,WebKit在加载的一个网页的时候,首先会为它创建一个Document对象。在创建这个Document对象的时候,WebKit会通过Chromium中的Content层,也就是调用一个RenderFrameImpl对象的成员函数didCreateDocumentElement。这个RenderFrameImpl对象是在Content层中描述的一个在当前Render进程中加载的网页的。

RenderFrameImpl类的成员函数didCreateDocumentElement在执行的过程中,会到上述的UserScriptSlave对象收集要在当前加载的网页中加载的Content Script。这些Content Script又会进一步交给JavaScript引擎在一个Isolated World中执行。Content Script在Isolated World中执行,意味着它们是被隔离执行的,也就是它们不能访问在宿主网页中定义的JavaScript函数和变量。

RenderFrameImpl类的成员函数didCreateDocumentElement是如何将Content Script交给JavaScript引擎执行的呢?从前面Chromium网页Frame Tree创建过程分析一文可以知道,Chromium的Content层的每一个RenderFrameImpl对象在WebKit中都对应有一个WebLocalFrameImpl对象,并且该WebLocalFrameImpl对象会保存在它对应的RenderFrameImpl对象的内部。这个WebLocalFrameImpl对象可以看作是WebKit向Chromium的Content层提供的一个API接口。通过调用这个WebLocalFrameImpl对象的成员函数executeScriptInIsolatedWorld,就可以将指定的Content Script交给JavaScript引擎执行了。

这样,我们就通过两个Extension例子,对Extension机制涉及到的基本概念进行了介绍。为了更进一步理解Extension机制,接下来我们将结合源码,按照以下四个情景对Extension机制进行分析:

1. Extension的加载过程

2. Extension Page的加载过程

3. Content Script的加载过程

  1. Extension Page之间,以及Extension Page与Content Script之间的通信过程

注意,这些文章的侧重点是分析Extension机制的实现,而不是Extension的开发。Extension的开发,可以参考官方文档:http://code.google.com/chrome/extensions/getstarted.html

另外,除了Extension机制,Chromium还提供了Plugin机制,用来增强浏览器的功能。Extension和Plugin是两种不同的机制。从狭义上讲,Plugin仅仅是用来增加网页的功能,而Extension不仅能用来增加网页的功能,也能增强浏览器本身功能。而且,两者的开发方式(开发语言和API接口)也是完全不一样的。为了更好地理解两者的区别,后面我们将会用另外一个系列的文章分析Chromium的Plugin机制。

敬请关注!更多的信息也可以关注老罗的新浪微博:http://weibo.com/shengyangluo

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8