IndexedDB in Service Worker 实战案例

1,561 阅读4分钟

一、Service Worker 是什么 ?

以下几种流行的解释:

  1. Service Worker 通过向典型的 Web 应用程序添加后台同步、离线渲染和推送通知等功能,缩小了本机应用程序和 Web 应用程序之间的差距,主要任务之一是充当代理。

  2. Service Worker 本质上是浏览器在后台运行的脚本,它是完全独立于它正在处理或服务的网页。它们充当了 web 应用程序、浏览器和网络之间的代理服务器。Service Worker 赋予 Web 应用程序像原生应用程序一样工作的能力。

  3. Service Worker 定义了由事件驱动的生命周期,这使得页面上任何网络请求事件都可以被其拦截并加以处理,同时还能访问缓存和IndexedDB,这就可以让开发者指定自定义更高的缓存管理策略,从而提高弱网络环境下的Web 运行体验。

  4. Service Worker,是现代web开发的关键部分,在最近几年获得了关注,这都要归功于 PWA(渐进式 Web 应用程序) 的流行。

二、Service Worker的作用 ?

  • 离线缓存(重点)
  • 消息推送(重点)
  • 后台数据同步
  • 响应来自其他源的资源请求
  • 集中接收计算机成本比较高的数据更新,比如地理位置和陀螺仪信息,这样多个页面就可以利用同一组数据
  • 在客户端进行CoffeeScript, LESS, CJS/AMD等模块编译和依赖管理(用于开发目的)
  • 后台服务钩子
  • 自定义模版用于特定URL模式
  • 性能增强,比如预取用户可能需要的资源,比如相册中的后面数张图片

三、Service Worker的特征

  1. Service Worker不能直接访问DOM,使用一种通过 postMessage 接口发送的消息机制来响应不使用时终止,这意味着它们是事件驱动的;
  2. Service Worker 使用 ES6 承诺机制;
  3. 使用Service Worker,则需要 HTTPS 的支持,如果在本地主机上可以在没有 HTTPS 支持的情况下使用它们;然而,如果上传到远程服务器,将需要 HTTPS 的支持。

四、Service worker的生命周期

Service Worker生命周期的反应: installing(安装) -> installed(已安装) -> activating(激活) -> activated(已激活)

install用来缓存文件, activate用来缓存更新

五、启用 Service Worker

// html中的script代码
if ("serviceWorker" in navigator) {
  //开始注册serivice workers
  navigator.serviceWoker
    .register("./sw.js", {
      scope: "./",
    })
    .then((registration) => {
      console.log("注册成功");
      let serviceWorker;
      if (registration.installing) {
        serviceWorker = registration.installing;
        console.log("安装installing");
      } else if (registration.waiting) {
        serviceWorker = registration.waiting;
        console.log("等待waiting");
      } else if (registration.active) {
        serviceWorker = registration.active;
        console.log("激活active");
      }
    })
    .catch((error) => {
      console.log("注册没有成功");
    });
} else {
  console.log("浏览器不支持service worker");
}
//sw.js
const version = 1;
const staticName = `staticCache-${version}`;
// 缓存
self.addEventListener("install", (event) => {
  console.log("Service Worker: Installed");
  event.waintUntil( //caches 是个全局变量,可以操作缓存空间
    caches.open(staticName).then((cache) => {
      return cache.addAll(["./index.html"]);
    })
  );
});

// 缓存更新
self.addEventListener("activate", (event) => {
  console.log("Service Worker activate");
  event.wainUntill(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          // 如果当前版本和缓存不一致,删除缓存
          if (cacheName !== staticName) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// 捕获请求并返回缓存数据
self.addEventListener("fetch", (event) => {
  console.log("Service Worker: Fetching");
  ev.respondWith(
    caches.open(staticName).then((cache) => {
      return cache.match(event.request).then((response) => {
        if (response) {
          //有缓存
          return response;
        }
        // 无缓存
        return fetch(event.request).then((response) => {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    })
  );
});

在Service Worker上下文中:

  • 第一: 不能访问DOM
  • 第二:不能访问 window,localStorage等对象
  • 第三: 这是个全新的上下文,只有一些特有的对象能访问,比如 self 代表全局作用域的对象
  • 第四: 关于Service Worker生命周期一共有三种监听事件

Service Worker 让离线缓存成为可能,offline 情况下也可以访问页面~

WX20220823-135225@2x.png

图1-1 Service Worker安装成功,本地可以手动卸载

WX20220823-135420@2x.png

图1-2 Cache Storage 存储了哪些静态资源

WX20220823-135440@2x.png

图1-3 Storage 的存储情况,手动清空Storage

WX20220823-135514@2x.png

图1-3 离线状态刷新页面,去 Service Worker 请求资源

六、IndexedDB 存储

IndexedDB是一种事务型数据库系统,其事物型类似基于SQL的关系数据库管理系统(RDBMS),但其并不像RDBMS使用固定列表,而是基于一种Javascript面向对象的数据库,更接近与NoSQL。

1. IndexedDB具有以下特点:

  • 储存空间大: IndexedDB的储存空间比LocalStorage大得多,一般来说不少于250MB,甚至没有上限;

  • 支持事务: IndexedDB支持事务(trasaction), 这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况;

  • 键值对储存: IndexedDB 内部采用对象仓库(object store)存放数据.所有类型的数据都可以直接存入,包括javascript对象,对象仓库中,数据以“键值对”的形式保存,每一条数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误;

  • 支持二进制储存: IndexedDB不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer对象和Blob对象);

  • 同源限制: IndexedDB受到同源限制,每一个数据库对应创建它的域名.网页只能访问自身域名下的数据库,而不能访问跨域的数据库;

  • 异步: IndexedDB操作时不会锁死浏览器,用户依然可以进行其他操作,这与Localstorage形成对比,后者的操作是同步的;异步设计是为了防止大量数据的读写,拖慢网页的表现。

2. 打开或者新建数据库

使用IndexedDB的第一步就是打开或新建一个数据库对象,具体如下:

//根据所指定数据库名称去打开数据库,若不存在则会新建数据库,第二个参数为数据库版本号
const request = window.indexedDB.open('my_indexeddb', '1.0');
// 数据库打开后的操作请求对象,通过三种事件处理打开操作的结果
request.onerror = function(event){console.log('数据库打开失败');}
// 数据库打开成功后,便可通过request对象中的result属性拿到数据库对象
request.onsuccess = function(event) {
    //数据库对象
    const db = request.result;
}
/**
 * 如果打开数据库时指定的版本号大于实际版本号,则会触发数据库升级事件
 * 而新数据库必然会触发该事件,通常新建数据库之后第一件事便是创建对象仓库对象,即创建表
 */
 request.onupgradeneeded = function(event) {
     const db = event.target.result;
     let objectStore;
     // 判断所要创建的person表对象是否存在,不存在再进行创建
     if(!db.obejctStoreNames.contains('person')) {
	objectStore = db.createObjectStore('person', {keyPath: 'id'})
     }
 }

3. 数据的增删改查

新增数据是指的是向对象仓库写入数据记录, 这需要通过事务完成:

function add() {
    var request = db.transaction(['person'], 'readwrite')
    .objectStore('person')
    .add({id: 1, name: '张三', age: 24, email: 'zhangsan@example.com'})
    request .onsuccess =. function(event) {
      console.log('数据写入成功')
    }
    request.onerror = function(event) {
      console.log('数据写入失败')
    }
}
add()

上面的代码中,写入数据需要新建一个事务.新建时必须指定表格名称和操作模式(“只读”或“读写”). 新建事务以后,通过 IDBTransaction.objectStore(name)方法,拿到IDBObjectStore对象,再通过表格对象的add()方法,向表格写入一条记录。

写入操作是一个异步操作,通过监听连接对象的success事件和error事件,了解是否写入成功。

读取数据也是通过事务完成:

function read() {
    var transaction = db.transaction(['person'])
    var objectStore = transaction.objectStore('person')
    var request = objectStore.get(1)
    request.onerror = function(event) {
      console.log('事务失败')
    }
    request.onsuccess = function(event) {
         if(request.result) {
            console.log('Name: ' + request.result.name);
             console.log('Age: ' + request.result.age);
            console.log('Email: ' + request.result.email);
          } else {
            console.log('未获得数据记录')
         }
    } 

}
read()

上面代码中, objectStore.get()方法用于读取数据,参数是主键的值。

遍历数据表格的所有记录,要使用指针对象 IDBCursor:

function readAll() {
  var objectStore = db.transaction('person').objectStore('person');
   objectStore.openCursor().onsuccess = function (event) {
     var cursor = event.target.result;
     if (cursor) {
       console.log('Id: ' + cursor.key);
       console.log('Name: ' + cursor.value.name);
       console.log('Age: ' + cursor.value.age);
       console.log('Email: ' + cursor.value.email);
       cursor.continue();
    } else {
      console.log('没有更多数据了!');
    }
  };
}
readAll();

上面代码中,新建指针对象的openCursor()方法是一个异步操作,所以要监听success事件。

更新数据要使用IDBObject.put()方法:

function update() {
    var request = db.transaction(['person'], 'readwrite')
    .objectStore('person')
    .put({id: 1, name: '李四', age: 35, email: 'lisi@example.com'})

    request.onsuccess =. function(event) {
            console.log('数据更新成功')
    }

    request.onerror = function(event) {
            console.log('数据更新失败')
    }
}
update()

上面的代码中, put()方法自动更新了主键为1的记录。

IDBObjectStore.delete()方法用于删除记录:

function remove() {
    var request = db.transaction(['person'], 'readwrite')
    .objectStore('person')
    .delete(1)
    request.onsuccess = function(event) {
      console.log('数据删除成功')
    }
}
remove()

4. 使用索引

索引的意义在于,可以让你搜索任意字段,也就是说从任意字段拿到数据记录。如果不建立索引,默认只能搜索主键(即从主键取值)。

假定新建表格的时候,对name字段建立了索引。

objectStore.createIndex('name', 'name', { unique: false });

现在,就可以从name找到对应的数据记录了。

var transaction = db.transaction(['person'], 'readonly');
var store = transaction.objectStore('person');
var index = store.index('name');
var request = index.get('李四');

request.onsuccess = function (e) {
  var result = e.target.result;
  if (result) {
    // ...
  } else {
    // ...
  }
}

IndexedDB存储这块,阮一峰老师讲解的比较具体,有兴趣的可以查看老师的博客: www.ruanyifeng.com/blog/2018/0…

Service Worker 和 IndexedDB 的基本概念、用法理的差不多了,概念讲得太多不如实战来着实在!

接下来我们以一个todo-list的实战案例,来看看怎怎么在Service Worker中做IndexedDB存储~

七、实战案例

Service Worker 中如何做 IndexedDB 存储? 接下来是一个添加fruits todo-list案例介绍,具体代码比较多,请耐心看完~

// index.html 页面内容
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" type="text/css" href="./main.css" />
    <title>IndexDB in Service Worker</title>
  </head>
  <body>
    <header>
      <h1>IndexDB in Service Worker</h1>
    </header>
    <main>
      <form id="form" name="colorFoem">
        <p>
          <label for="name">Fruits Name</label>
          <input type="text" id="name" name="name" />
        </p>
        <p>
          <label for="color">Fruits Color</label>
          <input type="color" id="color" name="color" />
        </p>
        <p>
          <button id="btnSave">Save</button>
        </p>
      </form>
      <ul id="fList">
        <!-- TODO: list of fruits and their colors -->
      </ul>
    </main>
    <script type="text/javascript" src="./app.js"></script>
  </body>
</html>

index.html 包含提交表单和list展示

// app.js
const APP = {
  SW: null,
  DB: null,
  version: 3,
  init() {
    // called after DOMContentLoaded
    // register our service worker
    APP.registerSW();
    document.getElementById("form").addEventListener("submit", APP.saveFruits);
    APP.opnenDB();
  },
  registerSW() {
    if ("serviceWorker" in navigator) {
      navigator.serviceWorker
        .register("./sw.js", {
          scope: "./",
        })
        .then(
          (registration) => {
            APP.SW =
              registration.installing ||
              registration.waiting ||
              registration.active;
          },
          (error) => {
            console.log("Service worker registration failed:", error);
          }
        );
      //listen for the latest sw
      navigator.serviceWorker.addEventListener("controllerchange", async () => {
        APP.SW = navigator.serviceWorker.controller;
      });

      //listen for message from the service worker
      navigator.serviceWorker.addEventListener("message", APP.onMessage);
    } else {
      console.log("Service workers are not supported.");
    }
  },
  saveFruits(ev) {
    ev.preventDefault();
    let name = document.getElementById("name");
    let color = document.getElementById("color");
    let strName = name.value.trim();
    let strColor = color.value.trim();
    if (strName && strColor) {
      let fruits = {
        id: Date.now(),
        name: strName,
        color: strColor,
      };
      console.log("Save", fruits);
      APP.sendMessage({
        addFruits: fruits,
      });
    }
  },
  sendMessage(msg) {
    console.log("APP.SW", APP.SW);
    if (APP.SW) {
      console.log("msg", msg);
      APP.SW.postMessage(msg);
    }
  },
  onMessage({ data }) {
    //got a message from the service worker
    console.log("Web page receving", data);
    //TODO: check for saveFruits and build the list and clear the from
    if ("saveFruits" in data) {
      APP.showFruits();
      document.getElementById("name").value = "";
    }
  },
  showFruits() {
    console.log("showFruits", APP.DB);
    //TODO: check for DB
    if (!APP.DB) {
      APP.opnenDB();
    }
    //TODO: start transaction to read names and build the list
    try {
      let tx = APP.DB.transaction("fruitsStore", "readonly");
      let store = tx.objectStore("fruitsStore");
      let req = store.getAll();
      req.onsuccess = (ev) => {
        console.log("onsuccess");
        let list = document.getElementById("fList");
        let data = ev.target.result;
        list.innerHTML = data
          .map((fruits) => {
            console.log("show", fruits);
            return `<li data-id="${fruits.id}">
					${fruits.name}
					<input type="color" value="${fruits.color}" disabled />
				</li>`;
          })
          .join("\n");
      };
    } catch (err) {
      console.log("colorStore is no create");
    }
  },
  opnenDB() {
    let req = window.indexedDB.open("fruitsDB", APP.version);
    req.onsuccess = (ev) => {
      APP.DB = ev.target.result;
      APP.showFruits();
    };
    req.onupgradeneeded = (ev) => {
      console.log("onupgradeneeded");
      let db = ev.target.result;
      if (!db.objectStoreNames.contains("fruitsStore")) {
        db.createObjectStore("fruitsStore", {
          keyPath: "id",
        });
      }
    };
  },
};

document.addEventListener("DOMContentLoaded", APP.init);

app.js 注册Service Worker, 收集表单内容,通过postMessage将表单内容通知Service Worker去添加IndexedDB存储数据,并接收Service Worker的添加成功消息指令,查询IndexedDB的内容,展示到列表中~

// sw.js 
const version = 3;
let staticName = `staticCache-${version}`;
let dynamicName = `dynamicChache`;
let imageName = `imageCache-${version}`;
// starter html add css js files
let assets = ["/", "./index.html", "./main.css", "./app.js", "./404.html"];
let DB = null;
self.addEventListener("install", (ev) => {
  console.log("Service Worker: Installed");
  ev.waitUntil(
    caches.open(staticName).then((cache) => {
      console.log("Service Worker: Caching Files");
      cache.addAll(assets);
    })
  );
});

self.addEventListener("activate", (ev) => {
  console.log("Service Worker activate");
  ev.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys
          .filter((key) => {
            if (key != staticName && key != imageName) {
              return true;
            }
          })
          .map((key) => caches.delete(key))
      ).then((empties) => {
        console.log("empties", empties);
        openDB();
      });
    })
  );
});

self.addEventListener("fetch", (ev) => {
  console.log("Service Worker: Fetching");
  ev.respondWith(
    caches.open(staticName).then((cache) => {
      return cache.match(ev.request).then((response) => {
        if (response) {
          //有缓存
          return response;
        }
        // 无缓存
        return fetch(ev.request).then((response) => {
          cache.put(ev.request, response.clone());
          return response;
        });
      });
    })
  );
});

self.addEventListener("message", (ev) => {
  console.log("message", ev);
  let data = ev.data;
  let clientId = ev.source.id;
  if ("addFruits" in data) {
    if (DB) {
      saveFruits(data.addFruits, clientId);
    } else {
      openDB(() => {
        saveFruits(data.addFruits, clientId);
      });
    }
  }

  if ("otherAction" in data) {
    let msg = "Hello";
    sendMessage({
      code: 0,
      message: msg,
    });
  }
});

const saveFruits = (fruits, clientId) => {
  if (fruits && DB) {
    let tx = DB.transaction("fruitsStore", "readwrite");
    tx.onerror = (err) => {
      //failed transaction
    };
    tx.oncomplete = (ev) => {
      let msg = "Thanks. The data was saved.";
      sendMessage(
        {
          code: 0,
          message: msg,
          saveFruits: fruits,
        },
        clientId
      );
    };
    let store = tx.objectStore("fruitsStore");
    let req = store.put(fruits);
    req.onsuccess = (ev) => {};
  } else {
    let msg = "No data was provied.";
    sendMessage(
      {
        code: 0,
        message: msg,
      },
      clientId
    );
  }
};

const sendMessage = async (msg, clientId) => {
  const client = await clients.get(clientId);
  if (!client) return;
  client.postMessage(msg);
};

const openDB = (callback) => {
  console.log("openDB");
  let req = self.indexedDB.open("fruitsDB", version);
  req.onerror = (err) => {
    //could not open db
    console.warn(err);
    DB = null;
  };
  req.onupgradeneeded = (ev) => {
    console.log("onupgradeneeded");
    let db = ev.target.result;
    if (!db.objectStoreNames.contains("fruitsStore")) {
      db.createObjectStore("fruitsStore", {
        keyPath: "id",
      });
    }
  };

  req.onsuccess = (ev) => {
    DB = ev.target.result;
    console.log("db opened and upgraded as needed");
    if (callback) {
      callback();
    }
  };
};

sw.js 监听Service Worker的几个生命周期函数,用Cache缓存静态资源及清空版本不一样的缓存,并且接收来自app应用的消息内容, 打开IndexedDB 添加存储数据,并将添加结果通过postMessage 通知 app应用~

WX20220823-152853@2x.png

图2-1 表单添加

WX20220823-153449@2x.png

图2-2 添加结果展示

WX20220823-152827@2x.png

图2-3 IndexedDB中的数据

八、结束语

上述的todo-list案例,已经涉及到Service Worker的注册安装、监听周期函数、缓存静态资源、离线访问、消息推送 等场景,然后在app主线程和Service Worker单独线程都去打开IndexedDB进行存储和查询数据,对IndexedDB在window和Service Worker中的应用场景基本上都有涉及到了;如果小伙伴们也能自己敲一下实例的代码,对Service Worker和IndexedDB的基本用法也能有个大概的了解了。

篇幅比较长,感谢阅读,前端攻城的路上大家一起加油~