שלב 2: יבוא של יישום אינטרנט קיים

בשלב הזה תלמדו:

  • איך להתאים אפליקציית אינטרנט קיימת לפלטפורמת אפליקציות Chrome.
  • איך מוודאים שהסקריפטים של האפליקציה עומדים בדרישות של Content Security Policy ‏ (CSP).
  • איך מטמיעים אחסון מקומי באמצעות chrome.storage.local.

זמן משוער להשלמת השלב הזה: 20 דקות.
כדי לראות מה תצטרכו להשלים בשלב הזה, אפשר לגלול למטה לתחתית הדף ↓.

ייבוא אפליקציית Todo קיימת

בתור התחלה, מייבאים לפרויקט את גרסת ה-JavaScript וניל של TodoMVC, אפליקציה נפוצה לבנצ'מרק.

כללנו גרסה של אפליקציית TodoMVC בקובץ ה-zip של קוד העזר בתיקייה todomvc. מעתיקים את כל הקבצים (כולל התיקיות) מ-todomvc לתיקיית הפרויקט.

מעתיקים את התיקייה todomvc לתיקיית codelab

תתבקשו להחליף את index.html. עכשיו צריך לאשר.

החלפת index.html

עכשיו אמור להיות בתיקיית האפליקציות מבנה הקבצים הבא:

תיקיית פרויקט חדשה

הקבצים שמודגשים בכחול הם מהתיקייה todomvc.

טוענים מחדש את האפליקציה עכשיו (לוחצים לחיצה ימנית > טוענים מחדש את האפליקציה). אתם אמורים לראות את ממשק המשתמש הבסיסי, אבל לא תוכלו להוסיף משימות.

איך מוודאים שהסקריפטים תואמים למדיניות Content Security Policy‏ (CSP)

פותחים את מסוף כלי הפיתוח (לוחצים לחיצה ימנית > בדיקת הרכיב ולאחר מכן בוחרים בכרטיסייה מסוף). תוצג שגיאה לגבי דחייה של הפעלת סקריפט בקוד:

אפליקציית Todo עם שגיאה ביומן מסוף של CSP

כדי לפתור את השגיאה הזו, נצטרך לוודא שהאפליקציה עומדת בדרישות של מדיניות אבטחת התוכן. אחת מהסיבות הנפוצות ביותר לפעולות שלא עומדות בדרישות של CSP היא JavaScript מוטמע. דוגמאות ל-JavaScript בשורה אחת כוללות בוררי אירועים כמאפייני DOM (למשל <button onclick=''>) ותגי <script> עם תוכן בתוך ה-HTML.

הפתרון פשוט: מעבירים את התוכן המוטבע לקובץ חדש.

1. ליד החלק התחתון של הדף index.html, מסירים את קוד ה-JavaScript המוטבע ובמקום זאת כוללים את js/shoestrap.js:

<script src="bower_components/director/build/director.js"></script>
<script>
  // Bootstrap app data
  window.app = {};
</script>
<script src="js/bootstrap.js"></script>
<script src="js/helpers.js"></script>
<script src="js/store.js"></script>

2. יוצרים קובץ בתיקייה js בשם bootrap.js. מעבירים את הקוד שקודם היה בשורה אחת לקובץ הזה:

// Bootstrap app data
window.app = {};

אם תטעינו מחדש את האפליקציה עכשיו, היא עדיין לא תפעל, אבל אנחנו מתקרבים לפתרון.

המרת localStorage ל-chrome.storage.local

אם תפתחו עכשיו את מסוף כלי הפיתוח, השגיאה הקודמת אמורה להיעלם. עם זאת, יש שגיאה חדשה לגבי window.localStorage שלא זמין:

אפליקציית משימות לביצוע עם שגיאת יומן של מסוף LocalStorage

אפליקציות Chrome לא תומכות ב-localStorage כי localStorage הוא אסינכרוני. גישה סינכרונית לחסימת משאבים (I/O) בסביבת זמן ריצה עם שרשור יחיד עלולה לגרום לאפליקציה להגיב.

לאפליקציות Chrome יש ממשק API מקביל שיכול לאחסן אובייקטים באופן אסינכרוני. כך תוכלו להימנע מתהליך היקר של אובייקט->string->אובייקטים.

כדי לטפל בהודעת השגיאה באפליקציה שלנו, צריך להמיר את localStorage ל-chrome.storage.local.

עדכון ההרשאות של האפליקציה

כדי להשתמש ב-chrome.storage.local, צריך לבקש את ההרשאה storage. בקובץ manifest.json, מוסיפים את "storage" למערך permissions:

"permissions": ["storage"],

מידע על local.storage.set() ו-local.storage.get()

כדי לשמור ולשלוף פריטים של 'דברים שצריך לעשות', צריך להכיר את השיטות set() ו-get() של API ‏chrome.storage.

המתודה set() מקבלת אובייקט של צמדי מפתח-ערך כפרמטר הראשון. הפרמטר השני הוא פונקציית קריאה חוזרת אופציונלית. לדוגמה:

chrome.storage.local.set({secretMessage:'Psst!',timeSet:Date.now()}, function() {
  console.log("Secret message saved");
});

השיטה get()‎ מקבלת פרמטר ראשון אופציונלי למפתחות של מאגר הנתונים שרוצים לאחזר. אפשר להעביר מפתח יחיד כמחרוזת, ומספר מפתחות אפשר לארגן במערך של מחרוזות או באובייקט מילון.

הפרמטר השני, שהוא חובה, הוא פונקציית קריאה חוזרת. באובייקט המוחזר, משתמשים במפתחות שביקשת בפרמטר הראשון כדי לגשת לערכים השמורים. לדוגמה:

chrome.storage.local.get(['secretMessage','timeSet'], function(data) {
  console.log("The secret message:", data.secretMessage, "saved at:", data.timeSet);
});

אם רוצים get() את כל מה שנמצא כרגע ב-chrome.storage.local, צריך להשמיט את הפרמטר הראשון:

chrome.storage.local.get(function(data) {
  console.log(data);
});

בניגוד ל-localStorage, לא תוכלו לבדוק פריטים שמאוחסנים באופן מקומי באמצעות חלונית המשאבים של כלי הפיתוח. עם זאת, אפשר לבצע פעולות ב-chrome.storage דרך מסוף JavaScript באופן הבא:

שימוש במסוף לניפוי באגים ב-chrome.storage

הצגה מקדימה של השינויים הנדרשים ב-API

רוב השלבים שנותרו בהמרת אפליקציית Todo הם שינויים קטנים בקריאות ל-API. צריך לשנות את כל המקומות שבהם נעשה כרגע שימוש ב-localStorage, למרות שזה כרוך בזמן רב ובסיכון לשגיאות.

ההבדלים העיקריים בין localStorage לבין chrome.storage נובעים מהאופי האסינכרוני של chrome.storage:

  • במקום לכתוב ל-localStorage באמצעות מטלה פשוטה, צריך להשתמש ב-chrome.storage.local.set() עם קריאות חוזרות (callback) אופציונליות.

    var data = { todos: [] };
    localStorage[dbName] = JSON.stringify(data);
    

    לעומת

    var storage = {};
    storage[dbName] = { todos: [] };
    chrome.storage.local.set( storage, function() {
      // optional callback
    });
    
  • במקום לגשת ישירות ל-localStorage[myStorageName], צריך להשתמש ב-chrome.storage.local.get(myStorageName,function(storage){...}) ואז לנתח את האובייקט storage שהוחזר בפונקציית הקריאה החוזרת.

    var todos = JSON.parse(localStorage[dbName]).todos;
    

    לעומת

    chrome.storage.local.get(dbName, function(storage) {
      var todos = storage[dbName].todos;
    });
    
  • הפונקציה .bind(this) משמשת בכל פונקציות ה-call back כדי לוודא ש-this מתייחס ל-this של אב הטיפוס Store. (מידע נוסף על פונקציות מקושרות זמין במסמכי העזרה של MDN: Function.prototype.bind()).

    function Store() {
      this.scope = 'inside Store';
      chrome.storage.local.set( {}, function() {
        console.log(this.scope); // outputs: 'undefined'
      });
    }
    new Store();
    

    לעומת

    function Store() {
      this.scope = 'inside Store';
      chrome.storage.local.set( {}, function() {
        console.log(this.scope); // outputs: 'inside Store'
      }.bind(this));
    }
    new Store();
    

חשוב לזכור את ההבדלים העיקריים האלה כשנלמד איך לאחזר, לשמור ולהסיר פריטים ברשימת המשימות בקטעים הבאים.

אחזור פריטים ברשימת המשימות

נעדכן את אפליקציית Todo כדי לאחזר את משימות המשימות:

1. שיטת המבנה (constructor) Store מטפלת בהפעלה הראשונית של אפליקציית Todo עם כל הפריטים הקיימים של המשימות במאגר הנתונים. השיטה בודקת קודם אם מאגר הנתונים קיים. אם לא, היא תיצור מערך ריק של todos ותשמור אותו במאגר הנתונים כדי שלא יהיו שגיאות קריאה בסביבת זמן הריצה.

בקובץ js/store.js, ממירים את השימוש ב-localStorage ב-method של ה-constructor לשימוש ב-chrome.storage.local:

function Store(name, callback) {
  var data;
  var dbName;

  callback = callback || function () {};

  dbName = this._dbName = name;

  if (!localStorage[dbName]) {
    data = {
      todos: []
    };
    localStorage[dbName] = JSON.stringify(data);
  }
  callback.call(this, JSON.parse(localStorage[dbName]));

  chrome.storage.local.get(dbName, function(storage) {
    if ( dbName in storage ) {
      callback.call(this, storage[dbName].todos);
    } else {
      storage = {};
      storage[dbName] = { todos: [] };
      chrome.storage.local.set( storage, function() {
        callback.call(this, storage[dbName].todos);
      }.bind(this));
    }
  }.bind(this));
}

2. השיטה find() משמשת לקריאת משימות מהמודל. התוצאות שמוחזרות משתנות בהתאם לסינון לפי 'הכול', 'פעיל' או 'הושלמה'.

המרה של find() לשימוש ב-chrome.storage.local:

Store.prototype.find = function (query, callback) {
  if (!callback) {
    return;
  }

  var todos = JSON.parse(localStorage[this._dbName]).todos;

  callback.call(this, todos.filter(function (todo) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var todos = storage[this._dbName].todos.filter(function (todo) {
      for (var q in query) {
         return query[q] === todo[q];
      }
      });
    callback.call(this, todos);
  }.bind(this));
  }));
};

3. בדומה ל-find(), הפונקציה findAll() מקבלת את כל המשימות מהדגם. המרה של findAll() לשימוש ב-chrome.storage.local:

Store.prototype.findAll = function (callback) {
  callback = callback || function () {};
  callback.call(this, JSON.parse(localStorage[this._dbName]).todos);
  chrome.storage.local.get(this._dbName, function(storage) {
    var todos = storage[this._dbName] && storage[this._dbName].todos || [];
    callback.call(this, todos);
  }.bind(this));
};

שמירת פריטים ב'דברים שצריך לעשות'

השיטה הנוכחית של save() מציבה אתגר. הדבר תלוי בשתי פעולות אסינכרוניות (get and set) שפועלות על כל אחסון ה-JSON המונוליתי בכל פעם. כל עדכון בכמות גדולה על יותר מפריט משימות אחד, כמו "mark כל המשימות לביצוע משימות שהושלמו", יגרום לסכנת נתונים שנקראת Read-After-Write. הבעיה הזו לא הייתה מתרחשת אם היינו משתמשים באחסון נתונים מתאים יותר, כמו IndexedDB, אבל אנחנו מנסים לצמצם את מאמץ ההמרה ב-Codelab הזה.

יש כמה דרכים לתקן את הבעיה, לכן נשתמש בהזדמנות הזו כדי לבצע שינויים קלים ב-save(), על ידי שימוש במערך של מזהי משימות שצריך לעדכן בבת אחת:

1. כדי להתחיל, צריך לעטוף את כל מה שכבר נמצא ב-save() ב-callback של chrome.storage.local.get():

Store.prototype.save = function (id, updateData, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = JSON.parse(localStorage[this._dbName]);
    // ...
    if (typeof id !== 'object') {
      // ...
    }else {
      // ...
    }
  }.bind(this));
};

2. ממירים את כל המופעים של localStorage באמצעות chrome.storage.local:

Store.prototype.save = function (id, updateData, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = JSON.parse(localStorage[this._dbName]);
    var data = storage[this._dbName];
    var todos = data.todos;

    callback = callback || function () {};

    // If an ID was actually given, find the item and update each property
    if ( typeof id !== 'object' ) {
      // ...

      localStorage[this._dbName] = JSON.stringify(data);
      callback.call(this, JSON.parse(localStorage[this._dbName]).todos);
      chrome.storage.local.set(storage, function() {
        chrome.storage.local.get(this._dbName, function(storage) {
          callback.call(this, storage[this._dbName].todos);
        }.bind(this));
      }.bind(this));
    } else {
      callback = updateData;

      updateData = id;

      // Generate an ID
      updateData.id = new Date().getTime();

      localStorage[this._dbName] = JSON.stringify(data);
      callback.call(this, [updateData]);
      chrome.storage.local.set(storage, function() {
        callback.call(this, [updateData]);
      }.bind(this));
    }
  }.bind(this));
};

3. לאחר מכן מעדכנים את הלוגיקה כך שתפעול על מערך במקום על פריט יחיד:

Store.prototype.save = function (id, updateData, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = storage[this._dbName];
    var todos = data.todos;

    callback = callback || function () {};

    // If an ID was actually given, find the item and update each property
    if ( typeof id !== 'object' || Array.isArray(id) ) {
      var ids = [].concat( id );
      ids.forEach(function(id) {
        for (var i = 0; i < todos.length; i++) {
          if (todos[i].id == id) {
            for (var x in updateData) {
              todos[i][x] = updateData[x];
            }
          }
        }
      });

      chrome.storage.local.set(storage, function() {
        chrome.storage.local.get(this._dbName, function(storage) {
          callback.call(this, storage[this._dbName].todos);
        }.bind(this));
      }.bind(this));
    } else {
      callback = updateData;

      updateData = id;

      // Generate an ID
      updateData.id = new Date().getTime();

      todos.push(updateData);
      chrome.storage.local.set(storage, function() {
        callback.call(this, [updateData]);
      }.bind(this));
    }
  }.bind(this));
};

סימון פריטים ברשימת המשימות כ'בוצעו'

עכשיו האפליקציה פועלת על מערכים, צריך לשנות את האופן שבו האפליקציה מטפלת במשתמש כשלוחצים על הלחצן נקה הושלמה (#):

1. בקובץ controller.js, מעדכנים את toggleAll() כך שיפעיל את toggleComplete() רק פעם אחת עם מערך של משימות במקום לסמן כל משימה כ'הושלמה' בנפרד. צריך למחוק גם את השיחה אל _filter(), כי תצטרכו לשנות את toggleComplete _filter().

Controller.prototype.toggleAll = function (e) {
  var completed = e.target.checked ? 1 : 0;
  var query = 0;
  if (completed === 0) {
    query = 1;
  }
  this.model.read({ completed: query }, function (data) {
    var ids = [];
    data.forEach(function (item) {
      this.toggleComplete(item.id, e.target, true);
      ids.push(item.id);
    }.bind(this));
    this.toggleComplete(ids, e.target, false);
  }.bind(this));

  this._filter();
};

2. עכשיו מעדכנים את toggleComplete() כך שיקבל גם משימה אחת וגם מערך של משימות. זה כולל העברה של filter() לתוך update(), במקום מחוץ לו.

Controller.prototype.toggleComplete = function (ids, checkbox, silent) {
  var completed = checkbox.checked ? 1 : 0;
  this.model.update(ids, { completed: completed }, function () {
    if ( ids.constructor != Array ) {
      ids = [ ids ];
    }
    ids.forEach( function(id) {
      var listItem = $$('[data-id="' + id + '"]');
      
      if (!listItem) {
        return;
      }
      
      listItem.className = completed ? 'completed' : '';
      
      // In case it was toggled from an event and not by clicking the checkbox
      listItem.querySelector('input').checked = completed;
    });

    if (!silent) {
      this._filter();
    }

  }.bind(this));
};

Count todo items

After switching to async storage, there is a minor bug that shows up when getting the number of todos. You'll need to wrap the count operation in a callback function:

1. In model.js, update getCount() to accept a callback:

  Model.prototype.getCount = function (callback) {
  var todos = {
    active: 0,
    completed: 0,
    total: 0
  };
  this.storage.findAll(function (data) {
    data.each(function (todo) {
      if (todo.completed === 1) {
        todos.completed++;
      } else {
        todos.active++;
      }
      todos.total++;
    });
    if (callback) callback(todos);
  });
  return todos;
};

2. Back in controller.js, update _updateCount() to use the async getCount() you edited in the previous step:

Controller.prototype._updateCount = function () {
  var todos = this.model.getCount();
  this.model.getCount(function(todos) {
    this.$todoItemCounter.innerHTML = this.view.itemCounter(todos.active);

    this.$clearCompleted.innerHTML = this.view.clearCompletedButton(todos.completed);
    this.$clearCompleted.style.display = todos.completed > 0 ? 'block' : 'none';

    this.$toggleAll.checked = todos.completed === todos.total;

    this._toggleFrame(todos);
  }.bind(this));

};

You are almost there! If you reload the app now, you will be able to insert new todos without any console errors.

Remove todos items

Now that the app can save todo items, you're close to being done! You still get errors when you attempt to remove todo items:

Todo app with localStorage console log error

1. In store.js, convert all the localStorage instances to use chrome.storage.local:

a) To start off, wrap everything already inside remove() with a get() callback:

Store.prototype.remove = function (id, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = JSON.parse(localStorage[this._dbName]);
    var todos = data.todos;

    for (var i = 0; i < todos.length; i++) {
      if (todos[i].id == id) {
        todos.splice(i, 1);
        break;
      }
    }

    localStorage[this._dbName] = JSON.stringify(data);
    callback.call(this, JSON.parse(localStorage[this._dbName]).todos);
  }.bind(this));
};

b) Then convert the contents within the get() callback:

Store.prototype.remove = function (id, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = JSON.parse(localStorage[this._dbName]);
    var data = storage[this._dbName];
    var todos = data.todos;

    for (var i = 0; i < todos.length; i++) {
      if (todos[i].id == id) {
        todos.splice(i, 1);
        break;
      }
    }

    localStorage[this._dbName] = JSON.stringify(data);
    callback.call(this, JSON.parse(localStorage[this._dbName]).todos);
    chrome.storage.local.set(storage, function() {
      callback.call(this, todos);
    }.bind(this));
  }.bind(this));
};

2. The same Read-After-Write data hazard issue previously present in the save() method is also present when removing items so you will need to update a few more places to allow for batch operations on a list of todo IDs.

a) Still in store.js, update remove():

Store.prototype.remove = function (id, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = storage[this._dbName];
    var todos = data.todos;

    var ids = [].concat(id);
    ids.forEach( function(id) {
      for (var i = 0; i < todos.length; i++) {
        if (todos[i].id == id) {
          todos.splice(i, 1);
          break;
        }
      }
    });

    chrome.storage.local.set(storage, function() {
      callback.call(this, todos);
    }.bind(this));
  }.bind(this));
};

b) In controller.js, change removeCompletedItems() to make it call removeItem() on all IDs at once:

Controller.prototype.removeCompletedItems = function () {
  this.model.read({ completed: 1 }, function (data) {
    var ids = [];
    data.forEach(function (item) {
      this.removeItem(item.id);
      ids.push(item.id);
    }.bind(this));
    this.removeItem(ids);
  }.bind(this));

  this._filter();
};

c) Finally, still in controller.js, change the removeItem() to support removing multiple items from the DOM at once, and move the _filter() call to be inside the callback:

Controller.prototype.removeItem = function (id) {
  this.model.remove(id, function () {
    var ids = [].concat(id);
    ids.forEach( function(id) {
      this.$todoList.removeChild($$('[data-id="' + id + '"]'));
    }.bind(this));
    this._filter();
  }.bind(this));
  this._filter();
};

הסרת כל הפריטים לביצוע משימות

יש שיטה נוספת ב-store.js שמשתמשת ב-localStorage:

Store.prototype.drop = function (callback) {
  localStorage[this._dbName] = JSON.stringify({todos: []});
  callback.call(this, JSON.parse(localStorage[this._dbName]).todos);
};

לא קוראים לשיטה הזו באפליקציה הנוכחית. אם אתם רוצים אתגר נוסף, נסו להטמיע אותה בעצמכם. רמז: כדאי לעיין במאמר chrome.storage.local.clear().

הפעלת אפליקציית Todo שהשלמתם

סיימתם את שלב 2. צריך לטעון מחדש את האפליקציה, ועכשיו אמורה להיות לכם גרסת TodoMVC ארוזת ל-Chrome שפועלת בצורה תקינה.

אפשר לקבל מידע נוסף

למידע מפורט יותר על כמה מממשקי ה-API שהוצגו בשלב הזה, אפשר לעיין במאמרים הבאים:

רוצה להמשיך לשלב הבא? עוברים אל שלב 3 – הוספת התראות וזעקות השכמה »