浏览代码

feat: faster load of popup, editor, dashboard

tophf 5 年之前
父节点
当前提交
bd58cba911

+ 5 - 3
src/background/index.js

@@ -1,6 +1,6 @@
 import { sendCmd } from '#/common';
 import { TIMEOUT_24HOURS, TIMEOUT_MAX } from '#/common/consts';
-import { forEachEntry, objectSet } from '#/common/object';
+import { deepCopy, forEachEntry, objectSet } from '#/common/object';
 import ua from '#/common/ua';
 import * as sync from './sync';
 import { commands } from './utils';
@@ -53,8 +53,8 @@ hookOptions((changes) => {
 
 Object.assign(commands, {
   /** @return {Promise<Object>} */
-  async GetData() {
-    const data = await getData();
+  async GetData(ids) {
+    const data = await getData(ids);
     data.sync = sync.getStates();
     return data;
   },
@@ -137,6 +137,8 @@ function autoUpdate() {
 }
 
 initialize(() => {
+  global.handleCommandMessage = handleCommandMessage;
+  global.deepCopy = deepCopy;
   browser.runtime.onMessage.addListener(
     ua.isFirefox // in FF a rejected Promise value is transferred only if it's an Error object
       ? (...args) => (

+ 3 - 0
src/background/utils/cache.js

@@ -12,6 +12,9 @@ Object.assign(commands, {
   CacheHit(data) {
     cache.hit(data.key, data.lifetime);
   },
+  CachePop(key) {
+    return cache.pop(key) || null;
+  },
 });
 
 export default cache;

+ 3 - 2
src/background/utils/db.js

@@ -331,9 +331,10 @@ function getIconUrls() {
  * @desc Get data for dashboard.
  * @return {Promise<{ scripts: VMScript[], cache: Object }>}
  */
-export async function getData() {
+export async function getData(ids) {
+  const { scripts } = store;
   return {
-    scripts: store.scripts,
+    scripts: ids ? ids.map(getScriptById) : scripts,
     cache: await storage.cache.getMulti(getIconUrls()),
   };
 }

+ 17 - 1
src/background/utils/popup-tracker.js

@@ -1,10 +1,16 @@
-import { sendTabCmd } from '#/common';
+import { getActiveTab, sendTabCmd } from '#/common';
+import cache from './cache';
 import { postInitialize } from './init';
+import { commands } from './message';
 
 export const popupTabs = {}; // { tabId: 1 }
 
 postInitialize.push(() => {
   browser.runtime.onConnect.addListener(onPopupOpened);
+  browser.webRequest.onBeforeRequest.addListener(prefetchSetPopup, {
+    urls: [browser.runtime.getURL(browser.runtime.getManifest().browser_action.default_popup)],
+    types: ['main_frame'],
+  });
 });
 
 function onPopupOpened(port) {
@@ -12,9 +18,19 @@ function onPopupOpened(port) {
   popupTabs[tabId] = 1;
   sendTabCmd(tabId, 'PopupShown', true);
   port.onDisconnect.addListener(onPopupClosed);
+  delete commands.SetPopup;
 }
 
 function onPopupClosed({ name }) {
   delete popupTabs[name];
   sendTabCmd(+name, 'PopupShown', false);
 }
+
+async function prefetchSetPopup() {
+  const tabId = (await getActiveTab()).id;
+  sendTabCmd(tabId, 'PopupShown', true);
+  commands.SetPopup = async (data, src) => {
+    data.metas = commands.GetMetas(data.ids);
+    cache.put('SetPopup', Object.assign({ [src.frameId]: [data, src] }, cache.get('SetPopup')));
+  };
+}

+ 1 - 7
src/common/browser.js

@@ -116,7 +116,7 @@ if (!global.browser?.runtime?.sendMessage) {
   global.browser = wrapAPIs(chrome, meta);
 } else if (process.env.DEBUG && !global.chrome.app) {
   let counter = 0;
-  const { runtime } = browser;
+  const { runtime } = global.browser;
   const { sendMessage, onMessage } = runtime;
   const log = (type, args, id, isResponse) => console.info(
     `%c${type}Message#%d${isResponse ? ' response' : ''}`,
@@ -144,9 +144,3 @@ if (!global.browser?.runtime?.sendMessage) {
     return result;
   });
 }
-
-// prefetch the options while the current extension page loads
-/* global browser */
-if (browser.tabs) {
-  global.allOptions = browser.runtime.sendMessage({ cmd: 'GetAllOptions' }).catch(() => {});
-}

+ 8 - 16
src/common/index.js

@@ -1,4 +1,5 @@
 import { browser } from '#/common/consts';
+import { deepCopy } from './object';
 import { noop } from './util';
 
 export { normalizeKeys } from './object';
@@ -39,6 +40,13 @@ export function sendCmd(cmd, data, options) {
   return sendMessage({ cmd, data }, options);
 }
 
+export function sendCmdDirectly(cmd, data, options) {
+  const bg = browser.extension.getBackgroundPage?.();
+  return bg && bg !== window
+    ? bg.handleCommandMessage(bg.deepCopy({ cmd, data })).then(deepCopy)
+    : sendCmd(cmd, data, options);
+}
+
 /**
  * @param {number} tabId
  * @param {string} cmd
@@ -113,22 +121,6 @@ export function isRemote(url) {
   return url && !(/^(file:|data:|https?:\/\/localhost[:/]|http:\/\/127\.0\.0\.1[:/])/.test(url));
 }
 
-export function cache2blobUrl(raw, { defaultType, type: overrideType } = {}) {
-  if (raw) {
-    const parts = `${raw}`.split(',');
-    const { length } = parts;
-    const b64 = parts[length - 1];
-    const type = overrideType || parts[length - 2] || defaultType || '';
-    // Binary string is not supported by blob constructor,
-    // so we have to transform it into array buffer.
-    const bin = window.atob(b64);
-    const arr = new window.Uint8Array(bin.length);
-    for (let i = 0; i < bin.length; i += 1) arr[i] = bin.charCodeAt(i);
-    const blob = new Blob([arr], { type });
-    return URL.createObjectURL(blob);
-  }
-}
-
 export function encodeFilename(name) {
   // `escape` generated URI has % in it
   return name.replace(/[-\\/:*?"<>|%\s]/g, (m) => {

+ 43 - 0
src/common/load-script-icon.js

@@ -0,0 +1,43 @@
+const images = {};
+
+/**
+ * Sets script's safeIcon property after the image is successfully loaded
+ * @param {VMScript} script
+ * @param {Object} [_]
+ * @param {?string} [_.default]
+ * @param {string} [_.key]
+ * @param {Object} [_.cache]
+ * @returns {Promise<boolean>}
+ */
+export function loadScriptIcon(script, {
+  default: defaultIcon = null,
+  key = 'safeIcon',
+  cache = {},
+} = {}) {
+  const { icon } = script.meta;
+  const url = script.custom?.pathMap?.[icon] || icon;
+  const isNewUrl = url !== script[key];
+  let promise = isNewUrl && url ? images[url] : Promise.resolve(false);
+  if (!promise) {
+    promise = Promise.resolve(cache?.[url] || fetchImage(url));
+    images[url] = promise;
+  }
+  if (isNewUrl || !url) {
+    // creates an observable property so Vue will see the change in then()
+    script[key] = defaultIcon;
+    promise.then(ok => {
+      script[key] = ok ? url : defaultIcon;
+    });
+  }
+  return promise;
+}
+
+async function fetchImage(url) {
+  try {
+    const blob = await (await fetch(url)).blob();
+    await createImageBitmap(blob);
+    return true;
+  } catch (e) {
+    return false;
+  }
+}

+ 3 - 6
src/common/options.js

@@ -1,14 +1,11 @@
 import defaults from '#/common/options-defaults';
-import { initHooks, sendCmd } from '.';
+import { initHooks, sendCmdDirectly } from '.';
 import { forEachEntry, objectGet, objectSet } from './object';
 
 let options = {};
 const hooks = initHooks();
-const ready = (global.allOptions || Promise.resolve())
-.then((data) => data || sendCmd('GetAllOptions', null, { retry: true }))
+const ready = sendCmdDirectly('GetAllOptions', null, { retry: true })
 .then((data) => {
-  delete global.allOptions;
-  ready.indeed = true; // a workaround for inability to query native Promise state
   options = data;
   if (data) hooks.fire(data);
 });
@@ -21,7 +18,7 @@ function setOption(key, value) {
   // the updated options object will be propagated from the background script after a pause
   // so meanwhile the local code should be able to see the new value using options.get()
   objectSet(options, key, value);
-  sendCmd('SetOptions', { key, value });
+  sendCmdDirectly('SetOptions', { key, value });
 }
 
 function updateOptions(data) {

+ 1 - 2
src/common/ui/setting-check.vue

@@ -34,8 +34,7 @@ export default {
       this.$emit('change', value);
     },
   },
-  async created() {
-    if (!options.ready.indeed) await options.ready;
+  created() {
     this.revoke = hookSetting(this.name, val => { this.value = val; });
     this.$watch('value', this.onChange);
   },

+ 1 - 2
src/common/ui/setting-text.vue

@@ -31,8 +31,7 @@ export default {
       error: null,
     };
   },
-  async created() {
-    if (!options.ready.indeed) await options.ready;
+  created() {
     const handle = this.json
       ? (value => JSON.stringify(value, null, '  '))
       // XXX compatible with old data format

+ 18 - 23
src/options/index.js

@@ -1,8 +1,6 @@
 import Vue from 'vue';
-import {
-  sendCmd, i18n, getLocaleString, cache2blobUrl,
-} from '#/common';
-import { forEachEntry, forEachValue } from '#/common/object';
+import { sendCmdDirectly, i18n, getLocaleString } from '#/common';
+import { forEachEntry } from '#/common/object';
 import handlers from '#/common/handlers';
 import options from '#/common/options';
 import loadZip from '#/common/zip';
@@ -51,27 +49,24 @@ function initScript(script) {
   script.$cache = { search, name, lowerName };
 }
 
-async function loadData() {
-  const data = await sendCmd('GetData', null, { retry: true });
-  if (!options.ready.indeed) await options.ready;
-  const oldCache = store.cache || {};
-  store.cache = data.cache;
-  store.sync = data.sync;
-  store.scripts = data.scripts;
-  if (store.scripts) {
-    store.scripts.forEach(initScript);
-  }
-  if (store.cache) {
-    store.cache::forEachEntry(([url, raw]) => {
-      if (oldCache[url]) {
-        store.cache[url] = oldCache[url];
-        delete oldCache[url];
-      } else {
-        store.cache[url] = cache2blobUrl(raw, { defaultType: 'image/png' });
-      }
+export async function loadData() {
+  const id = store.route.paths[1];
+  const params = id ? [+id].filter(Boolean) : null;
+  const [{ cache, scripts, sync }] = await Promise.all([
+    sendCmdDirectly('GetData', params, { retry: true }),
+    options.ready,
+  ]);
+  if (cache) {
+    const oldCache = store.cache || {};
+    cache::forEachEntry(([url, raw]) => {
+      const res = oldCache[url] || raw && `data:image/png;base64,${raw.split(',').pop()}`;
+      if (res) cache[url] = res;
     });
   }
-  oldCache::forEachValue(URL.revokeObjectURL);
+  scripts?.forEach(initScript);
+  store.scripts = scripts;
+  store.cache = cache;
+  store.sync = sync;
   store.loading = false;
 }
 

+ 3 - 30
src/options/views/script-item.vue

@@ -98,30 +98,12 @@
 <script>
 import Tooltip from 'vueleton/lib/tooltip/bundle';
 import { sendCmd, getLocaleString, formatTime } from '#/common';
-import { objectGet } from '#/common/object';
+import { loadScriptIcon } from '#/common/load-script-icon';
 import Icon from '#/common/ui/icon';
 import { store } from '../utils';
 import enableDragging from '../utils/dragging';
 
 const DEFAULT_ICON = '/public/images/icon48.png';
-const images = {};
-function loadImage(url) {
-  if (!url) return Promise.reject();
-  let promise = images[url];
-  if (!promise) {
-    const cache = store.cache[url];
-    promise = cache
-      ? Promise.resolve(cache)
-      : new Promise((resolve, reject) => {
-        const img = new Image();
-        img.onload = () => resolve(url);
-        img.onerror = () => reject(url);
-        img.src = url;
-      });
-    images[url] = promise;
-  }
-  return promise;
-}
 
 export default {
   props: [
@@ -198,17 +180,8 @@ export default {
     },
   },
   mounted() {
-    const { icon } = this.script.meta;
-    if (icon && icon !== this.safeIcon) {
-      const pathMap = objectGet(this.script, 'custom.pathMap') || {};
-      const fullUrl = pathMap[icon] || icon;
-      loadImage(fullUrl)
-      .then((url) => {
-        this.safeIcon = url;
-      }, () => {
-        this.safeIcon = DEFAULT_ICON;
-      });
-    }
+    loadScriptIcon(this.script, { default: DEFAULT_ICON, cache: store.cache })
+    .then(() => { this.safeIcon = this.script.safeIcon; });
     enableDragging(this.$el, {
       onDrop: (from, to) => this.$emit('move', { from, to }),
     });

+ 5 - 1
src/options/views/tab-installed.vue

@@ -137,6 +137,7 @@ import LocaleGroup from '#/common/ui/locale-group';
 import { forEachKey } from '#/common/object';
 import { setRoute, lastRoute } from '#/common/router';
 import storage from '#/common/storage';
+import { loadData } from '#/options';
 import ScriptItem from './script-item';
 import Edit from './edit';
 import { store, showConfirmation, showMessage } from '../utils';
@@ -348,7 +349,10 @@ export default {
         this.script = nid && this.scripts.find(script => script.props.id === nid);
         if (!this.script) {
           // First time showing the list we need to tell v-if to keep it forever
-          this.canRenderScripts = true;
+          if (!this.canRenderScripts) {
+            loadData();
+            this.canRenderScripts = true;
+          }
           this.debouncedRender();
           // Strip the invalid id from the URL so |App| can render the aside,
           // which was hidden to avoid flicker on initial page load directly into the editor.

+ 1 - 2
src/options/views/tab-settings/index.vue

@@ -204,9 +204,8 @@ export default {
       };
     },
   },
-  async created() {
+  created() {
     this.revokers = [];
-    if (!options.ready.indeed) await options.ready;
     items.forEach((item) => {
       const { name, normalize } = item;
       this.revokers.push(hookSetting(name, val => { settings[name] = normalize(val); }));

+ 12 - 6
src/popup/index.js

@@ -1,15 +1,15 @@
 import Vue from 'vue';
-import { getActiveTab, i18n, sendCmd } from '#/common';
+import { getActiveTab, i18n, sendCmdDirectly } from '#/common';
 import { INJECT_PAGE, INJECTABLE_TAB_URL_RE } from '#/common/consts';
 import handlers from '#/common/handlers';
-import { mapEntry } from '#/common/object';
+import { loadScriptIcon } from '#/common/load-script-icon';
+import { forEachValue, mapEntry } from '#/common/object';
 import * as tld from '#/common/tld';
 import '#/common/ui/style';
 import App from './views/app';
 import { store } from './utils';
 
 tld.initTLD();
-
 Vue.prototype.i18n = i18n;
 
 const vm = new Vue({
@@ -30,7 +30,7 @@ mutex.ready = new Promise(resolve => {
 
 Object.assign(handlers, {
   async SetPopup(data, src) {
-    if (store.currentTab.id !== src.tab.id) return;
+    if (store.currentTab && store.currentTab.id !== src.tab.id) return;
     const isTop = src.frameId === 0;
     if (!isTop) await mutex.ready;
     const ids = data.ids.filter(id => !allScriptIds.includes(id));
@@ -42,7 +42,9 @@ Object.assign(handlers, {
     if (ids.length) {
       // frameScripts may be appended multiple times if iframes have unique scripts
       const scope = store[isTop ? 'scripts' : 'frameScripts'];
-      scope.push(...await sendCmd('GetMetas', ids));
+      const metas = data.metas || await sendCmdDirectly('GetMetas', ids);
+      metas.forEach(script => loadScriptIcon(script, { cache: store.cache }));
+      scope.push(...metas);
       data.failedIds.forEach(id => {
         scope.forEach((script) => {
           if (script.props.id === id) {
@@ -57,6 +59,10 @@ Object.assign(handlers, {
   },
 });
 
+sendCmdDirectly('CachePop', 'SetPopup').then((data) => {
+  data::forEachValue(val => handlers.SetPopup(...val));
+});
+
 getActiveTab()
 .then(async (tab) => {
   const { url } = tab;
@@ -70,6 +76,6 @@ getActiveTab()
   if (!INJECTABLE_TAB_URL_RE.test(url)) {
     store.injectable = false;
   } else {
-    store.blacklisted = await sendCmd('TestBlacklist', url);
+    store.blacklisted = await sendCmdDirectly('TestBlacklist', url);
   }
 });

+ 1 - 0
src/popup/utils/index.js

@@ -1,4 +1,5 @@
 export const store = {
+  cache: {},
   scripts: [],
   frameScripts: [],
   commands: [],

+ 1 - 5
src/popup/views/app.vue

@@ -67,7 +67,7 @@
           <div
             class="menu-item menu-area"
             @click="onToggleScript(item)">
-            <img class="script-icon" :src="scriptIconUrl(item)" @error="scriptIconError">
+            <img class="script-icon" :src="item.data.safeIcon" @error="scriptIconError">
             <icon :name="getSymbolCheck(item.data.config.enabled)"></icon>
             <div class="flex-auto ellipsis" v-text="item.name"
                  :class="{failed: item.data.failed}"
@@ -215,10 +215,6 @@ export default {
     getSymbolCheck(bool) {
       return `toggle-${bool ? 'on' : 'off'}`;
     },
-    scriptIconUrl(item) {
-      const { icon } = item.data.meta;
-      return (item.data.custom.pathMap || {})[icon] || icon || null;
-    },
     scriptIconError(event) {
       event.target.removeAttribute('src');
     },