2단계: 기존 웹 앱 가져오기

이 단계에서 학습할 내용은 다음과 같습니다.

  • 기존 웹 애플리케이션을 Chrome 앱 플랫폼에 맞게 조정하는 방법
  • 앱 스크립트가 콘텐츠 보안 정책 (CSP)을 준수하도록 하는 방법
  • chrome.storage.local을 사용하여 로컬 저장소를 구현하는 방법

이 단계를 완료하는 데 걸리는 예상 시간: 20분
이 단계에서 완료할 작업을 미리 보려면 이 페이지 하단으로 이동 ↓합니다.

기존 Todo 앱 가져오기

먼저 공통 벤치마크 앱인 TodoMVCvanilla JavaScript 버전을 프로젝트로 가져옵니다.

todomvc 폴더의 참조 코드 zip에 TodoMVC 앱 버전이 포함되어 있습니다. todomvc의 모든 파일 (폴더 포함)을 프로젝트 폴더로 복사합니다.

todomvc 폴더를 Codelab 폴더에 복사

index.html을 대체하라는 메시지가 표시됩니다. 수락해 주세요.

index.html 바꾸기

이제 애플리케이션 폴더에 다음과 같은 파일 구조가 표시됩니다.

새 프로젝트 폴더

파란색으로 강조표시된 파일은 todomvc 폴더에 있는 파일입니다.

지금 앱을 새로고침합니다 (마우스 오른쪽 버튼으로 클릭 > 앱 새로고침). 기본 UI가 표시되지만 할 일은 추가할 수 없습니다.

스크립트가 콘텐츠 보안 정책 (CSP)을 준수하도록 설정

DevTools 콘솔을 엽니다 (마우스 오른쪽 버튼으로 클릭 > 요소 검사를 클릭한 다음 콘솔 탭 선택). 인라인 스크립트 실행을 거부하면 다음과 같은 오류가 표시됩니다.

CSP 콘솔 로그 오류가 있는 Todo 앱

앱이 콘텐츠 보안 정책을 준수하도록 변경하여 이 오류를 해결해 보겠습니다. 가장 일반적인 CSP 미준수 중 하나는 인라인 자바스크립트입니다. 인라인 JavaScript의 예에는 DOM 속성 (예: <button onclick=''>)으로 이벤트 핸들러와 HTML 내부에 콘텐츠가 있는 <script> 태그가 있습니다.

솔루션은 간단합니다. 인라인 콘텐츠를 새 파일로 옮기는 것입니다.

1. index.html 하단에서 인라인 자바스크립트를 삭제하고 대신 js/bootstrap.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 폴더에 bootstrap.js라는 파일을 만듭니다. 이전의 인라인 코드를 다음 파일로 이동합니다.

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

지금 앱을 새로고침하면 곧 작동하지 않는 Todo 앱이 계속 표시됩니다.

localStorage 파일을 chrome.storage.local로 변환합니다.

지금 DevTools 콘솔을 열면 이전 오류가 사라질 것입니다. 그러나 window.localStorage를 사용할 수 없다는 새로운 오류가 발생했습니다.

localStorage 콘솔 로그 오류가 있는 Todo 앱

localStorage가 동기식이므로 Chrome 앱에서는 localStorage를 지원하지 않습니다. 단일 스레드 런타임에서 차단 리소스 (I/O)에 동기식으로 액세스하면 앱이 응답하지 않을 수 있습니다.

Chrome 앱에는 객체를 비동기식으로 저장할 수 있는 동등한 API가 있습니다. 이렇게 하면 때때로 비용이 많이 드는 객체->문자열->객체 직렬화 프로세스를 피할 수 있습니다.

앱의 오류 메시지를 해결하려면 localStoragechrome.storage.local로 변환해야 합니다.

앱 권한 업데이트

chrome.storage.local를 사용하려면 storage 권한을 요청해야 합니다. manifest.json에서 "storage"permissions 배열에 추가합니다.

"permissions": ["storage"],

local.storage.set() 및 local.storage.get()에 대해 알아보기

할 일 항목을 저장하고 검색하려면 chrome.storage API의 set()get() 메서드에 관해 알아야 합니다.

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);
});

현재 chrome.storage.local에 있는 모든 항목을 get()하려면 첫 번째 매개변수를 생략합니다.

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

localStorage와 달리 DevTools 리소스 패널을 사용하여 로컬에 저장된 항목을 검사할 수 없습니다. 그러나 다음과 같이 JavaScript 콘솔에서 chrome.storage와 상호작용할 수 있습니다.

콘솔을 사용하여 chrome.storage 디버그

필수 API 변경사항 미리보기

Todo 앱을 변환하는 나머지 단계는 대부분 API 호출을 약간 변경하는 것입니다. 시간이 오래 걸리고 오류가 발생하기 쉽지만 현재 localStorage가 사용되는 모든 위치를 변경해야 합니다.

localStoragechrome.storage의 주요 차이점은 chrome.storage의 비동기 특성 때문입니다.

  • 간단한 할당을 사용하여 localStorage에 쓰는 대신 선택적 콜백과 함께 chrome.storage.local.set()를 사용해야 합니다.

    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) 함수는 thisStore 프로토타입의 this를 참조하도록 하기 위해 모든 콜백에서 사용됩니다. 바인드된 함수에 관한 자세한 내용은 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. Store 생성자 메서드는 데이터 스토어의 모든 기존 할 일 항목으로 Todo 앱을 초기화합니다. 이 메서드는 먼저 데이터 스토어가 있는지 확인합니다. 그렇지 않으면 todos의 빈 배열을 만들어 Datastore에 저장하므로 런타임 읽기 오류가 발생하지 않습니다.

js/store.js에서는 생성자 메서드에서 localStorage를 사용하는 대신 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() 메서드는 모델에서 할 일을 읽을 때 사용됩니다. 반환된 결과는 '전체', '활성' 또는 '완료됨' 중 무엇을 기준으로 필터링하는지에 따라 변경됩니다.

chrome.storage.local를 사용하도록 find()를 변환합니다.

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()는 모델의 모든 할 일을 가져옵니다. chrome.storage.local를 사용하도록 findAll()를 변환합니다.

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() 메서드에는 문제가 있습니다. 매번 전체 모놀리식 JSON 스토리지에서 작동하는 2가지 비동기 작업 (get 및 set)에 따라 달라집니다. 둘 이상의 할 일 항목에 대한 일괄 업데이트(예: '모든 할 일을 완료로 표시')는 쓰기 후 읽기라는 데이터 위험을 야기합니다. IndexedDB와 같이 더 적절한 데이터 스토리지를 사용하면 이 문제가 발생하지 않지만 이 Codelab의 변환 작업을 최소화하려고 합니다.

문제를 해결하는 방법에는 여러 가지가 있으므로 이 기회를 활용하여 할 일 ID의 배열을 한 번에 모두 업데이트하여 save()를 약간 리팩터링할 것입니다.

1. 먼저 save() 내에 이미 있는 모든 것을 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. chrome.storage.local를 사용하여 모든 localStorage 인스턴스를 변환합니다.

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));
};

할 일 항목을 완료로 표시

이제 앱이 배열에서 작동하므로 Clear completed (#) 버튼을 클릭하는 사용자를 처리하는 방법을 변경해야 합니다.

1. controller.js에서 할 일을 하나씩 완료된 것으로 표시하는 대신 할 일 배열로 toggleComplete()를 한 번만 호출하도록 toggleAll()를 업데이트합니다. toggleComplete _filter()를 조정하므로 _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. 이제 단일 todo 또는 todo 배열을 모두 허용하도록 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단계를 완료했습니다. 앱을 새로고침하면 완전히 작동하는 Chrome 패키지 버전의 TodoMVC가 생성됩니다.

추가 정보

이 단계에서 도입된 일부 API에 관한 자세한 내용은 다음을 참고하세요.

다음 단계로 진행할 준비가 되셨나요? 3단계 - 알람 및 알림 추가 »로 이동합니다.