Using Angular in extension on a page that already

2019-01-13 11:04发布

问题:

I'm writing a Chrome Extension (here) for my library that uses angular for its UI. It works great on pages that don't use angular, but it causes issues with pages that do have angular. For example, on the Angular docs page:

Uncaught Error: [$injector:modulerr] Failed to instantiate module docsApp due to:
  Error: [$injector:nomod] Module 'docsApp' is not available! You either misspelled the module name or forgot to load it. If registering a module ensure that you specify the dependencies as the second argument.
  http://errors.angularjs.org/1.2.7/$injector/nomod?p0=docsApp
at chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:78:14
at chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:1528:19
at ensure (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:1453:40)
at module (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:1526:16)
at chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:3616:24
at Array.forEach (native)
at forEach (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:302:13)
at loadModules (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:3610:7)
at createInjector (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:3550:13)
at doBootstrap (chrome-extension://cfgbockhpgdlmcdlcbfmflckllmdiljo/angular.js:1298:22)
http://errors.angularjs.org/1.2.7/$injector/modulerr?p0=docsApp&p1=Error%3A…xtension%3A%2F%2Fcfgbockhpgdlmcdlcbfmflckllmdiljo%2Fangular.js%3A1298%3A22) angular.js:78

The weird thing is that it seems to happen whether my extension actually uses angular or not. All I need to reproduce the issue is to include angular in my manifest.json as a content_script and this error is thrown. Any ideas of how I could make this work without messing up an angular site would be greatly appreciated.

Like I said, it doesn't matter whether I actually use angular or not, but this is all I'm doing to use it:

makeWishForAnchors(); // This just loads the global genie object. I don't believe it's related.

var lamp = '<div class="genie-extension"><div ux-lamp lamp-visible="genieVisible" rub-class="visible" local-storage="true"></div></div>';
$('body').append(lamp);

angular.module('genie-extension', ['uxGenie']);
angular.bootstrap($('.genie-extension')[0], ['genie-extension']);

Thanks!

回答1:

The problem

As soon as Angular is injected, it parses the DOM looking for an element with the ng-app directive. If one is found Angular will bootstrap automatically. This becomes a problem when a page uses Angular itself, because (although they have separate JS execution contexts) the page and the content script share the same DOM.

The solution

You need to prevent your Angular instance (by "your" I mean the one injected by your extension as a content script) from automatically bootstrapping. Normally you would just omit the ng-app directive and you would be good to go, but since you do not have control over the original DOM (nor do you want to break the page's functionality) this is not an option.

What you can do it use manual bootstrapping for your Angular app in conjunction with deferred bootstrapping (to prevent your Angular from trying to automatically bootstrap the page's Angular app).

At the same time, you need to "protect" (i.e. hide) your app's root element from the page's Angular instance. To achieve this, you can wrap your root element in a parent element with the ngNonBindable directive, so the page's Angular instance will leave it alone.

Summarizing the steps from the above docs, you need to do the following:

  1. Prepend window.name with NG_DEFER_BOOTSTRAP! prior to injecting your Angular.
    E.g. inject a tiny script (before angluar.js) containing just one line:

    window.name = 'NG_DEFER_BOOTSTRAP!' + window.name;
    
  2. Wrap your app's root element in a parent element with the attribute ng-non-bindable:

    var wrapper = ...    // <div ng-non-bindable></div>
    wrapper.appendChild(lamp);   // or whatever your root element is
    document.body.appendChild(wrapper);
    
  3. In your app's main script, manually bootstrap your Angular app:

    var appRoot = document.querySelector('#<yourRootElemID');
    angular.bootstrap(appRoot, ['genie-extension']);
    

Fine print: I haven't tested it myself, but I promise to do so soon !


UPDATE

The code below is intended as a proof of concept for the approach described above. Basically, it is a demo extension that loads an Angular-powered content-script into any http:/https: page whenever the browser-action button is clicked.

The extension takes all necessary precautions in order not to interfere with (or get broken by) the page's own Angular instance (if any).

Finally, I had to add a third requirement (see the updated solution description above) to protect/hide the content-script's Angular app from the page's Angular instance.
(I.e. I wrapped the root element in a parent element with the ngNonBindable directive.)

manifest.json:

{
  "manifest_version": 2,
  "name": "Test Extension",
  "version": "0.0",

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

  "browser_action": {
    "default_title": "Test Extension"
//    "default_icon": {
//      "19": "img/icon19.png",
//      "38": "img/icon38.png"
//    },
  },

  "permissions": ["activeTab"]
}

background.js:

// Inject our Angular app, taking care
// not to interfere with page's Angular (if any)
function injectAngular(tabId) {
  // Prevent immediate automatic bootstrapping
  chrome.tabs.executeScript(tabId, {
    code: 'window.name = "NG_DEFER_BOOTSTRAP!" + window.name;'
  }, function () {
    // Inject AngularJS
    chrome.tabs.executeScript(tabId, {
      file: 'angular-1.2.7.min.js'
    }, function () {
      // Inject our app's script
      chrome.tabs.executeScript(tabId, {file: 'content.js'});
    });
  });
}

// Call `injectAngular()` when the user clicks the browser-action button
chrome.browserAction.onClicked.addListener(function (tab) {
  injectAngular(tab.id);
});

content.js:

// Create a non-bindable wrapper for the root element
// to keep the page's Angular instance away
var div = document.createElement('div');
div.dataset.ngNonBindable = '';
div.style.cssText = [
  'background:  rgb(250, 150, 50);',
  'bottom:      0px;',
  'font-weight: bold;',
  'position:    fixed;',
  'text-align:  center;',
  'width:       100%;',
  ''].join('\n');

// Create the app's root element (everything else should go in here)
var appRoot = document.createElement('div');
appRoot.dataset.ngController = 'MyCtrl as ctrl';
appRoot.innerHTML = 'Angular says: {{ctrl.message}}';

// Insert elements into the DOM
document.body.appendChild(div);
div.appendChild(appRoot);

// Angular-specific code goes here (i.e. defining and configuring
// modules, directives, services, filters, etc.)
angular.
  module('myApp', []).
  controller('MyCtrl', function MyCtrl() {
    this.message = 'Hello, isolated world !';
  });

/* Manually bootstrap the Angular app */
window.name = '';   // To allow `bootstrap()` to continue normally
angular.bootstrap(appRoot, ['myApp']);
console.log('Boot and loaded !');

Fine print:
I have conducted some preliminary tests (with both Angular and non-Angular webpages) and everything seems to work as expected. Yet, I have by no means tested this approach thoroughly !


Should anyone be interested, this is what it took to "Angularize" Genie's lamp.



回答2:

Answer of @ExpertSystem is pretty cool. But there on more case with same error. If you define angular in manifest.json in content-script section, like below:

"content_scripts": [
    {
      "css": ["app.css"],
      "js": [
        "libs/jquery/dist/jquery.min.js",
        "libs/angular/angular.min.js",
        "content.js"
      ],
      "matches": ["\u003Call_urls\u003E"]
    }
  ],

You may faced this error (like I am)



回答3:

@ExpertSystem 's answer is good, but it doesn't work for me, I think it may not a solution for each scenarios, so in the end I decide to edit the source file of angular.js.

I found angular.js will listen a ready event at the end of the file (in 1.4.6):

jqLite(document).ready(function() {
  angularInit(document, bootstrap);
});

Delete these lines, and everything clear.

If you use Bower, you can add a simple hook to do that when bower install or bower update.

In .bowerrc file:

{
  "scripts" : {
    "postinstall" : "node ./strip"
  }
}

In strip.js:

const fs  = require( 'fs' ) ,
filePath  = './bower_components/angular/angular.js' ,
angularJs = fs.readFileSync( filePath , 'utf8' ) ,
deleteStr = /jqLite\(document\)\.ready\(function\(\)[\s\S]+?\}\);/ ,
isDeleted = angularJs.search( deleteStr ) < 0;

if ( !isDeleted ) {
  fs.writeFileSync( filePath , angularJs.replace( deleteStr , '' ) );
  console.log( 'successful disabled auto-bootstrap' );
} else {
  console.log( 'auto-bootstrap already disabled.' );
}