Создание кроссбраузерной оболочки для пользовательских скриптов

Здравствуйте, уважаемые хабражители. Постов про пользовательские скрипты (userscripts) было на хабре немало, тем не менее, они только показывали, как ими пользоваться. А в работе юзерскриптов достаточно много кроссбраузерных несовместимостей (как и в любой области браузерного js). Естественно, можно установить различные дополнения для разных браузеров, однако, в случае написание скрипта для конечного пользователя, придётся сопровождать его огромным readme по установке компонент для обеспечения нормальной его работы. Что лично меня, да и вас, полагаю, тоже, не очень-то устраивает.

В данной статье речь будет вестись о трёх браузерах: Mozilla Firefox (с установленным GreaseMonkey), Google Chrome, Opera. Целью статьи является «заготовка», которая позволит пользовательскому скрипту работать одинаковым образом во всех перечисленных браузерах. Реализация GM API рассматриваться не будет, т.к. таковых уже сотни. Предполагается, что читатель уже знаком с общими правилами написания юзерскриптов (в случае, если нет, рекомендую сначала прочитать другую статью).


И непосредственно к теме. Начнём с директив. Несовместимостей здесь несколько: во-первых, в опере поддерживается только include, а в хроме — только match. Firefox поддерживает и то, и другое. Соответственно, нужно указать обе директивы (внезапно). Во-вторых, для поддержки unsafeWindow в firefox нужна директива @unwrap. Не буду уточнять, что перед использованием unsafeWindow нужно сильно подумать, правда ли это так необходимо. Итак, начальная часть нашей заготовки выглядит примерно так:
// ==UserScript==
// @name script_name
// @author author's name
// @version 1.0
// @description example
// @unwrap
// @run-at document-end
// @include http://example.com/*
// @match http://example.com/*
// ==/UserScript==


Плюс в Chrome директива match имеет св-во отваливаться, поэтому нелишним будет дописать в начало строчку типа такой:
if (location.hostname !== "example.com")
 return;


Теперь поговорим об области видимости. В Google Chrome пользовательские скрипты всегда выполняются в отдельной области видимости — в то время как в опере, наоборот, они внаглую выполняются прямо посреди страницы, грозя уничтожением всем глобальным переменным. Поэтому для безопасности неплохо бы добавить дополнительное замыкание:
// ==UserScript==
// @name script_name
// @author author's name
// @version 1.0
// @description example
// @unwrap
// @run-at document-end
// @include http://example.com/*
// @match http://example.com/*
// ==/UserScript==

(function(){
 if (location.hostname !== "example.com")
  return;

})();


И, наконец, такая немаловажная вещь, как доступ к глобальным объектам. В Mozilla Firefox доступ к ним осуществляется через переменную unsafeWindow, в Opera скрипт выполняется непосредственно в области видимости страницы, следовательно, глобальные переменные доступны через window, в Google Chrome доступа к глобальным объектам нет вовсе (печаль). Однако unsafeWindow там можно сэмулировать посредством добавления на страницу объекта, onclick которого нам возвращает window. Собственно, код с комментариями:

// если переменной unsafeWindow у нас нету - мы в опере, и её надо объявить,
// чтобы она не выпала в window
var unsafeWindow= this.unsafeWindow;
// чтобы переменные, используемые для эмуляции unsafeWindow, не мешали другим частям скрипта,
// добавляем дополнительное замыкание
(function(){
    // т.к., несмотря на то, что он не несёт никакого функционала,
    // объект unsafeWindow в chrome, тем не менее, присутствует, проверка будет изощрённой.
    var test_scr= document.createElement("script");
    // создаём новую переменную с уникальным именем
    var tid= ("t" + Math.random() + +(new Date())).replace(/\./g, "");
    // и бросаем её в глобальную область видимости.
    test_scr.text= "window."+tid+"=true";
    document.querySelector("body").appendChild(test_scr);
    // если у нас нет объекта unsafeWindow,
    // или если он не несёт никакой функциональной нагрузки
    if (typeof(unsafeWindow) == "undefined" || !unsafeWindow[tid]) {
        if (window[tid]) {
            // т.к. в опере window - и есть оригинальный window,
            // просто добавляем синоним
            unsafeWindow= window;
        } else {
            // а это - для гугл хрома
            var scr= document.createElement("script");
            scr.text= "(" +
            (function() {
                var el= document.createElement('unsafeWindow');
                el.style.display= 'none';
                el.onclick=function(){return window};
                document.body.appendChild(el);
            }).toString() + ")()"; // копируем текст ф-ции в файл script и добавляем его на страницу
            document.querySelector("body").appendChild(scr);
            this.unsafeWindow= document.querySelector("unsafeWindow").onclick();
            // экспериментальным путём установлено, что если присвоить результат
            // onclick переменной unsafeWindow сразу, то работать оно не будет
            unsafeWindow= this.unsafeWindow;
        };
    }
})(); // профит


Итак, в конечном итоге наша заготовка будет выглядеть так:
// ==UserScript==
// @name script_name
// @author author's name
// @version 1.0
// @description example
// @unwrap
// @run-at document-end
// @include http://example.com/*
// @match http://example.com/*
// ==/UserScript==

(function(){
    if (location.hostname !== "example.com")
        return;
    var unsafeWindow= this.unsafeWindow;
    (function(){
        var test_scr= document.createElement("script");
        var tid= ("t" + Math.random() + +(new Date())).replace(/\./g, "");
        test_scr.text= "window."+tid+"=true";
        document.querySelector("body").appendChild(test_scr);
        if (typeof(unsafeWindow) == "undefined" || !unsafeWindow[tid]) {
            if (window[tid]) {
                unsafeWindow= window;
            } else {
                var scr= document.createElement("script");
                scr.text= "(" +
                    (function() {
                        var el= document.createElement('unsafeWindow');
                        el.style.display= 'none';
                        el.onclick=function(){return window};
                        document.body.appendChild(el);
                    }).toString() + ")()";
                document.querySelector("body").appendChild(scr);
                this.unsafeWindow= document.querySelector("unsafeWindow").onclick();
                unsafeWindow= window.unsafeWindow;
            };
        }
    })();
    // начиная отсюда, можно писать код
})();


Собственно, вот и всё. Надеюсь, кому-то поможет эта статья.

P. S. Ах да. Для отладки юзерскриптов в Firefox можно обернуть код вашего скрипта в try...catch конструкцию такого вида:
try{
// код скрипта
} catch(e){console.error(e)}
Облегчает жизнь очень сильно.

UPD: Огромное спасибо spmbt за такое хорошее дополнение к статье. Скорее, статья — дополнение к тому, что он написал…

UPD2: Директива include не даст работать скрипту в хроме на ненужных сайтах, тем не менее, при установке скрипта, если не указывать директиву match, будет сказано, что скрипт работает на всех страницах. Поэтому лучше указывать и директиву match.
Share post

Comments 10

    +15
    (Сильно недостаточно советов.)
    try{
    // код скрипта
    } catch(e){console.error(e)}
    

    Хотелось бы дополнить, что это нужно в Fx и Opera, а Chrome и без того даёт качественные сообщения и ссылки на строку ошибки.
    С учётом Fx лучше писать такую обёртку:
    (function(){
    var u ='undefined'
    	, win = typeof unsafeWindow !=u ? unsafeWindow: window;
    //... далее, код, не требующий контроля ошибок
    try{
    //...код с контролем ошибок
    }catch(er){
    	win.console.log("~~ER_global: "+ er +' (line '+(er.lineNumber||'')+')')}; //для оповещения об ошибках в Fx
    })()
    

    Совет 2-й: раз уж пишем функцию-обёртку, почему бы не использовать её:
    (function(win, u, noConsole, FAST){ //всякие нужные константы
    //...тело скрипта
    })(typeof unsafeWindow !='undefined'? unsafeWindow: window,'undefined',1,1);
    


    Совет 3-й: используйте метаданные не только в одном месте:
    (function(){var alienFrame = /(plusone\.google\.com|userscripts\.org)/.test(location.host)
    	, currMetaTx = !alienFrame && function(s){return(s=
    //если Firefox+GreaseMonkey, требуется удалить "/*" перед "<!", чтобы читались многострочные данные!
    /*<![CDATA[*//*
    // ==UserScript==
    // @name HabrAjax
    // @version 0.82_2012-03-27
    // @namespace spmbt.kodingen.com/index.htm
    // @author spmbt0
    // @description Cumulative script with over 20 functions for Fx-Opera-Chrome-Safari
    // @include http://habrahabr.ru/*
    // @include https://plusone.google.com/*
    // @include http://userscripts.org/scripts/source/*
    // @exclude http://habrahabr.ru/api/*
    // @resource meta 121690.meta.js
    // @update 0.82 механизм плагинов и модулей через CustomEvent (Fx6+, Chrome, Safari)
    // @update 0.815 совместимость со скриптом переключателя режимов "Все блоги - Избранные" (HabrAllHub)
    // @icon data:image/gif;base64,R0lGODlhIAAgAMMBAG6Wyv///2+NtIucstfY2b/FzpSmvY+QkM3Nzunp6fLy8qGwweDg4MbFxa2trrm6uiwAAAAAIAAgAAAE/xDISau9OM/AOe2edoHBBwqiRZodmrKhRLqXYFfrdmLCQBQGWk62swgOiERAQQgChs9iRZBMKDgEFGn...
    // ==/UserScript==
    */s//]]>
    )}	//-вернёт false, если не продолжать; 'false' (строку) - если Fx; иначе - строки метаданных
    	, u ='undefined'
    	, isUsfW = typeof unsafeWindow !=u
    	, win = isUsfW ? unsafeWindow: window
    	, isFxGmS = win !==window
    	, isFxScr = typeof GM_getMetadata !=u
    	, readMeta = function(s, isFxScr){ //парсинг многострочного текста по мета-директивам
    		if(typeof s !='string') //очистка оболочки функций, выделение мн-стр-комментария
    			s = typeof s=='function'
    				? ((/\*/.test(function(){/**/}+1) ? s : s(!1) )+'')
    						.replace(/(^[\s\S]*\*\/\/\*\r?\n?|\r?\n?\*\/s[\s\S]*$)/gm,'')
    				: (typeof s !=u && s!==null && s.toString ? s.toString() :''); //здесь же- 'xml'
    		var metaD ={}, j =0;
    		if(s==='false' && isFxScr){ //isFxScr - получать ли данные средствами Scriptish
    			metaD = GM_getMetadata();
    			for(var i in metaD){ //приведение к нормальному виду
    				if(metaD[i].length ==1)
    					metaD[i] = metaD[i][0];
    				j++;
    			}
    		}else{
    			var meta = s.split('\n'), aa, a2;
    			for(var i=0, mL = meta.length; i < mL; i++){
    				if(( aa = /^.*?\/\/\s*@([\S]+)\s(\s*)(.*)/g.exec(meta[i]) )){
    					a2 = aa[3] !==undefined && aa[3] || aa[2];
    					if(metaD[aa[1]]===undefined)
    						metaD[aa[1]] = a2;
    					else{
    						if(! (metaD[aa[1]] instanceof Array))
    							metaD[aa[1]] = [metaD[aa[1]]];
    						metaD[aa[1]].push(a2);
    					}
    					j++;
    				}else
    					if(!/^.*?\/\/\s*[\-=]*\s*\/?\s*UserScript\s*[\-=]*\s*$/i.test(meta[i]))
    						metaD[j++] = meta[i];
    			}
    		}
    		metaD._length = j; //число ключей хеша
    		return j >1 && metaD || undefined; //хеш директив + нум.список простых строк + _length -чис.простых строк или und., если не найдено
    	},
    	metaD = readMeta(currMetaTx, isFxScr); //теперь можно читать метаданные в этом хеше (кроссбраузерно!)
    

    простите, если «многобукв». В общем, по коду заголовка вы догадались где искать пример этой фичи.

    Совет 4-й. Советует использовать в связке с советом 3 не GreaseMonkey (ещё раз, НЕ GreaseMonkey!) а Scriptish. Потому что для GreaseMonkey в этой фиче — принципиально неустранимая некроссбраузерность (я, кажется, об этом писал в статье, но не уверен, может быть лежит в черновиках).

    Совет 5-й. Получайте кроссбраузерно внешние метаданные — от самого скрипта, от других скриптов, от анонсов скриптов на ваших серверах. Пример кода смотрите там же, откуда он взят для совета 3, ибо было бы ещё почти столько же.

    Совет 6-й. Если в коде вы сделали синтаксическую ошибку, легко обнаружить её поможет только Хром. Запустите неработающий скрипт в Хроме, это путь к спасению, он укажет путь к просветл пропущенной запятой.

    Совет 7-й. Метаться в Хром иногда лениво, поэтому озаботьтесь хорошим механизмом трассировки, она очень-очень пригодится. Уже имеется win.console.log, но это длинно и не очень удобно. Делаем так:

    var wcl = function(){ //консоль как метод строки или функция, с отключением по noConsole ==1
    	if(win.console && !noConsole)
    		win.console.log.apply(console, this instanceof String
    			? ["'=="+this+"'"].concat([].slice.call(arguments)) : arguments);
    };
    String.prototype.wcl = wcl;
    
    (впрочем, это полезно и в простых скриптах).
    Что получили? Можем писать

    'myVar'.wcl(myWar);
    

    Почему так? Потому что их будет много, без меток трассировочные значения будут все на одно лицо. ЗАчем там '=='+...? Чтобы лучше видеть. Зачем в апострофах? Чтобы лучше находить в коде по ctrl-F. (И теперь видно, зачем здесь константа noConsole из совета 2?)

    Совет 8-й. Содержите кроссбраузерную функцию кроссдоменного обмена по postMessage, она отличается от функции для простых скриптов. (А в Хроме она и для внутридоменного понадобится.) Я об этом обещал написать, скоро напишу, статья готова, а реализацию можно увидеть опять же, в том же источнике примеров.

    Совет 9-й. (Думаю, ещё не сучно.) Используйте легковесную библиотеку, не поддерживающую IE, если не собираетесь (с большой вероятностью, думается, это так) поддерживать юзерскрипты там. Zepto, AtomJs, Backbone или что-то небольшое своё — оно всегда пригодится — работа с DOM, с событиями, массивами, хешами. Впрочем, примеров и образцов нет. В моих примерах есть, но очень примитивное ядро на 2-3 К, не советовал бы брать за основу.

    Совет 10-й (продожение 9-го). Среди полезных исполльзуемых функцих, кроме работы с DOM, будут и:
    подгрузка стилей:
    addRules: function(css){
    	if(typeof GM_addStyle != "undefined"){ GM_addStyle(css);
    	}else if(typeof PRO_addStyle != "undefined"){ PRO_addStyle(css);
    	}else if(typeof addStyle != "undefined"){ addStyle(css);
    	}else{
    		var heads = document.getElementsByTagName("head");
    		if(heads.length){
    			var node = document.createElement("style");
    			node.type = "text/css";
    			node.appendChild(document.createTextNode(css));
    			heads[0].appendChild(node);
    	}}
    }
    
    ,
    подгрузка сторонних скриптов (кода многовато на все случаи, приводить не буду),
    специфическое ожидание целевого условия по таймеру (очень частая функция), для примера ищите execCallback в HabrAjax,
    генерация User Event и CustomEvent — полезная техника,
    показ подсказок по наведению мыши (примеров нет),
    открывание ссылок в фрейме, если нужно для задачи,
    группа функций работы с настройками вашего скрипта.

    Вот это — я понимаю, полезные советы, вполне достаточные для работы с юзерскриптами…

      +5
      Очередной случай того, когда комментарии превосходят саму статью? Даже и не знаю, что теперь в избранное добавлять…
        0
        Обидно, такой хороший коммент, а я плюс поставить не могу. Ну, тогда просто спасибо)

        Изначально статья задумывалась как пример эмуляции unsafeWindow под хромом — гугл не давал нормальных ответов, как это сделать. А в процессе я немного увлёкся. Так что да, статья точно неполная.

        var u ='undefined'
            , win = typeof unsafeWindow !=u ? unsafeWindow: window;

        Это не будет работать под хромом — там unsafeWindow есть, но нет у него того функционала.
        Опять же, присвоение переменной значения this.unsafeWindow — на порядок короче, и будет работать и под лисой так же.
        +2
        Кстати, @include прекрасно работает в Chrome. Проверено тысячами пользователей моих user-js.
          0
          Сейчас проверил — не работает. Но у меня не хром, хромиум под линуксом… Сейчас выберусь под винду, проверю под хромом. Тем не менее — можно пример?

          Тысячи пользователей… Позвольте полюбопытствовать: что вы пишете?
            0
            Просто плагины для браузерной игрушки.

            services.mib-oldbk.com/
              0
              Зашёл, попробовал открыть ваш скрипт… Хром сказал, что он будет иметь доступ ко всем сайтам.
                0
                И что с того? Вы на закладку Network смотрите. Напишите скрипт с @include и проверьте.
          0
          И правда, так include работает. Тем не менее, имхо, лучше указывать обе директивы — для большей наглядности для конечного пользователя.
            0
            Упс, забыл нажать «Ответить».

          Only users with full accounts can post comments. Log in, please.