說明:這是對Odoo官方Owl公開的資料的翻譯。以後再新增我在Odoo的Owl原始碼中領悟到的技術說明
這篇文章的確很燒腦,但是也只能從這裡先學習,以後可以回過頭來再看看這篇文章,會覺得很清晰。
建議先學習typescript和Qweb的知識
對於本教程,我們將構建一個非常簡單的待辦事項列表應用程式。 該應用程式應滿足以下要求:
讓使用者建立和刪除任務任務可以標記為已完成可以過濾任務以顯示活動/已完成的任務這個專案將是發現和學習一些重要的Owl概念的機會,例如元件,儲存以及如何組織應用程式。
1.建立專案對於本教程,我們將做一個非常簡單的專案,其中包含靜態檔案,並且沒有其他工具。 第一步是建立以下檔案結構:
該應用程式的入口點是檔案index.html,該檔案應具有以下內容:
(function () { console.log("hello owl", owl.__info__.version);})();
請注意,我們將所有內容放入立即執行的函式中,以避免將任何內容洩漏到全域性範圍。
最後,owl.js應該是從Owl儲存庫下載的最新版本(如果願意,可以使用owl.min.js)。
現在,該專案應該已經準備好了。 將index.html檔案載入到瀏覽器中時,應顯示一個空白頁面,標題為Owl Todo App,並且應在控制檯中記錄一條訊息,例如hello owl 1.0.0。
2.新增第一個元件Owl應用程式由元件組成(https://github.com/odoo/owl/blob/master/doc/reference/component.md),具有單個根元件。 讓我們首先定義一個App元件。 通過以下程式碼替換app.js中函式的內容:
const { Component } = owl;const { xml } = owl.tags;const { whenReady } = owl.utils;// Owl Componentsclass App extends Component { static template = xml`<div>todo app</div>`;}// Setup codefunction setup() { const app = new App(); app.mount(document.body);}whenReady(setup);
現在,在瀏覽器中重新載入頁面應該顯示一條訊息。
程式碼非常簡單,但是讓我們更詳細地解釋最後一行。瀏覽器嘗試儘快執行app.js中的javascript程式碼,並且當我們嘗試安裝App元件時,可能發生DOM尚未準備就緒的情況。為了避免這種情況,我們使用whenReady幫助程式將setup函式的執行延遲到DOM準備就緒為止。
注意1:在更大的專案中,我們將程式碼分成多個檔案,元件位於子資料夾中,而主檔案則將初始化應用程式。但是,這是一個很小的專案,我們希望使其儘可能簡單。
注意2:本教程使用靜態類欄位語法。並非所有瀏覽器都支援此功能。大多數實際專案都會轉換其程式碼,因此這不是問題,但是對於本教程而言,如果您需要在每個瀏覽器上都能使用的程式碼,則需要將每個static關鍵字轉換為對該類的分配:
class App extends Component {}App.template = xml`<div>todo app</div>`;
注意3:使用xml helper(/file/2020/09/12/20200912004114_1.jpg.md 一些編輯器在這種情況下支援語法突出顯示。 例如,VS Code具有附加Comment tagged template,如果已安裝,它將正確顯示標籤模板:
3.顯示任務列表現在已經完成了基礎工作,是時候開始考慮任務了。 為了完成我們需要的工作,我們將使用以下鍵將任務作為物件陣列來跟蹤:
id:一個數字。 擁有一種唯一標識任務的方法非常有用。 由於標題是使用者建立/編輯的內容,因此無法保證其唯一性。 因此,我們將為每個任務生成一個唯一的ID號。title:一個字串,用來說明任務的含義。isCompleted:一個布林值,用於跟蹤任務的狀態現在,我們決定了狀態的內部格式,讓我們向App元件新增一些演示資料和模板:
該模板包含一個t-foreach迴圈以迭代任務。 因為元件是渲染上下文,所以它可以從元件中找到tasks列表。 請注意,我們將每個任務的id用作t-key,這很常見。 有兩個CSS類:task-list和task,我們將在下一節中使用它們。
最後,請注意t-att-checked屬性的使用:通過t-att為屬性新增字首可以使其具有動態性。 Owl將評估表示式並將其設定為屬性的值。
4.佈局:一些基本的CSS到目前為止,我們的任務列表看起來很糟糕。 讓我們將以下內容新增到app.css中:
.task-list { width: 300px; margin: 50px auto; background: aliceblue; padding: 10px;}.task { font-size: 18px; color: #111111;}
這個更好。 現在,讓我們新增一個額外的功能:完成的任務的樣式應略有不同,以使它們變得不那麼重要。 為此,我們將在每個任務上新增一個動態CSS類:
<div class="task" t-att-class="task.isCompleted ? 'done' : ''">
.task.done { opacity: 0.7;}
注意,這裡我們還使用了動態屬性。
5.提取任務作為子元件現在很明顯,應該有一個Task元件來封裝任務的外觀和行為。
這個Task元件將顯示一個任務,但是不能擁有該任務的狀態:一條資料應該只有一個所有者。 否則會帶來麻煩。 因此,Task元件將獲得其資料作為prop。 這意味著資料仍歸App元件所有,但可以由Task元件使用(無需修改)。
由於我們在移動程式碼,因此是重構程式碼的好機會:
這裡發生了很多事情:
首先,我們現在在檔案頂部定義了一個子元件Task,每當我們定義子元件時,都需要將其新增到其父元件的靜態components(/file/2020/09/12/20200912004118_2.jpg.md class="todo-app"> <input placeholder="Enter a new task" t-on-keyup="addTask"/> <div class="task-list"> <t t-foreach="tasks" t-as="task" t-key="task.id"> <Task task="task"/> </t> </div></div>.todo-app { width: 300px; margin: 50px auto; background: aliceblue; padding: 10px;}.todo-app > input { display: block; margin: auto;}.task-list { margin-top: 8px;}
現在,我們有了一個有效的輸入,只要使用者添加了任務,該輸入就會登入到控制檯。 請注意,當您載入頁面時,輸入未聚焦。 但是新增任務是任務列表的核心功能,因此讓我們通過集中輸入使其儘可能快。
由於App是元件,因此它具有可實現的mounted lifecycle method(/file/2020/09/12/20200912004119_3.jpg.md 我們還需要通過使用帶有useRef鉤子的t-ref指令來獲得對輸入的引用:
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
// on top of file:const { useRef } = owl.hooks;
// in AppinputRef = useRef("add-input");mounted() { this.inputRef.el.focus();}
inputRef被定義為類欄位,因此等效於在建構函式中對其進行定義。 它僅指示Owl使用相應的t-ref關鍵字保留對任何內容的引用。 然後,我們實現mounted的生命週期方法,現在我們有了一個活動引用,可以用來集中輸入。
7.新增任務(第2部分)在上一節中,我們完成了所有工作,除了實現了實際建立任務的程式碼! 所以,讓我們現在開始。
我們需要一種生成唯一id號的方法。 為此,我們只需在App中新增一個nextId號。 同時,讓我們在App中刪除演示任務:
nextId = 1;tasks = [];
現在,可以實現addTask方法:
addTask(ev) { // 13 is keycode for ENTER if (ev.keyCode === 13) { const title = ev.target.value.trim(); ev.target.value = ""; if (title) { const newTask = { id: this.nextId++, title: title, isCompleted: false, }; this.tasks.push(newTask); } }}
這幾乎可以用,但是如果您對其進行測試,則會發現當用戶按下Enter鍵時,不會顯示任何新任務。 但是,如果新增debugger或console.log語句,您將看到程式碼實際上按預期執行。 問題在於,Owl無法知道它是否需要重新呈現使用者介面。 我們可以通過使用useState掛鉤使tasks具有反應性來解決此問題:
// on top of the fileconst { useRef, useState } = owl.hooks;// replace the task definition in App with the following:tasks = useState([]);
現在可以正常工作了!
8.切換任務如果您嘗試將任務標記為已完成,則可能已經注意到文字的不透明度沒有改變。 這是因為沒有程式碼可以修改isCompleted標誌。
現在,這是一個有趣的情況:任務由Task元件顯示,但它不是其狀態的所有者,因此無法修改它。 相反,我們希望傳達將任務切換到App元件的請求。 由於App是Task的父級,因此我們可以在Task中觸發 trigger (https://github.com/odoo/owl/blob/master/doc/reference/event_handling.md)事件並在App中監聽。
在Task中,將input更改為:
<input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/>
並新增toggleTask方法:
toggleTask() { this.trigger('toggle-task', {id: this.props.task.id});}
現在,我們需要在App模板中監聽該事件:
<div class="task-list" t-on-toggle-task="toggleTask">
並實現toggleTask程式碼:
toggleTask(ev) { const task = this.tasks.find(t => t.id === ev.detail.id); task.isCompleted = !task.isCompleted;}
9.刪除任務
現在讓我們新增執行刪除任務的可能性。 為此,我們首先需要在每個任務上新增一個廢紙icon圖示,然後像上一節中一樣繼續進行。
首先,讓我們更新Task模板,css和js:
<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''"> <input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/> <span><t t-esc="props.task.title"/></span> <span class="delete" t-on-click="deleteTask"></span></div>
.task { font-size: 18px; color: #111111; display: grid; grid-template-columns: 30px auto 30px;}.task > input { margin: auto;}.delete { opacity: 0; cursor: pointer; text-align: center;}.task:hover .delete { opacity: 1;}
deleteTask() { this.trigger('delete-task', {id: this.props.task.id});}
現在,我們需要監聽App中的delete-task事件:
<div class="task-list" t-on-toggle-task="toggleTask" t-on-delete-task="deleteTask">
deleteTask(ev) { const index = this.tasks.findIndex(t => t.id === ev.detail.id); this.tasks.splice(index, 1);}
10.使用儲存看一下程式碼,很明顯,我們現在有了處理分散在多個地方的任務的程式碼。 而且,它混合了UI程式碼和業務邏輯程式碼。 Owl有一種與使用者介面分開管理狀態的方法:Store(https://github.com/odoo/owl/blob/master/doc/reference/store.md)。
讓我們在應用程式中使用它。 (對於我們的應用程式而言)這是一個相當大的重構,因為它涉及從元件中提取所有與任務相關的程式碼。 這是app.js檔案的新內容:
11-在本地儲存中儲存任務現在,我們的TodoApp可以很好地執行,除非使用者關閉或重新整理瀏覽器! 僅將應用程式的狀態儲存在記憶體中確實很不方便。 為了解決這個問題,我們將任務儲存在本地儲存中。 使用我們當前的程式碼庫,這是一個簡單的更改:僅需要更新設定程式碼。
關鍵是要使用這樣的事實,即商店是一個EventBus(https://github.com/odoo/owl/blob/master/doc/reference/event_bus.md),它在每次更新時都會觸發一個update事件。
12.過濾任務我們差不多完成了,我們可以新增/更新/刪除任務。 唯一缺少的功能是可以根據任務的完成狀態顯示任務。 我們將需要跟蹤App中過濾器的狀態,然後根據其值過濾可見任務。
// on top of file, readd useState:const { useRef, useDispatch, useState, useStore } = owl.hooks;// in App:filter = useState({value: "all"})get displayedTasks() { switch (this.filter.value) { case "active": return this.tasks.filter(t => !t.isCompleted); case "completed": return this.tasks.filter(t => t.isCompleted); case "all": return this.tasks; }}setFilter(filter) { this.filter.value = filter;}
最後,我們需要顯示可見的過濾器。 我們可以做到這一點,同時在主列表下方的小面板中顯示任務數:
<div class="todo-app"> <input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/> <div class="task-list"> <t t-foreach="displayedTasks" t-as="task" t-key="task.id"> <Task task="task"/> </t> </div> <div class="task-panel" t-if="tasks.length"> <div class="task-counter"> <t t-esc="displayedTasks.length"/> <t t-if="displayedTasks.length lt tasks.length"> / <t t-esc="tasks.length"/> </t> task(s) </div> <div> <span t-foreach="['all', 'active', 'completed']" t-as="f" t-key="f" t-att-class="{active: filter.value===f}" t-on-click="setFilter(f)" t-esc="f"/> </div> </div></div>
.task-panel { color: #0088ff; margin-top: 8px; font-size: 14px; display: flex;}.task-panel .task-counter { flex-grow: 1;}.task-panel span { padding: 5px; cursor: pointer;}.task-panel span.active { font-weight: bold;}
請注意,這裡我們使用物件語法動態設定了過濾器的類:每個鍵都是要設定為真的類。
13.最後的接觸我們的清單功能齊全。 我們仍然可以新增一些額外的細節來改善使用者體驗。
當用戶滑鼠懸停在任務上時,新增視覺反饋:.task:hover { background-color: #def0ff;}
使任務標題可單擊,以切換其複選框:
<input type="checkbox" t-att-checked="props.task.isCompleted" t-att-id="props.task.id" t-on-click="dispatch('toggleTask', props.task.id)"/><label t-att-for="props.task.id"><t t-esc="props.task.title"/></label>
敲打已完成任務的標題:.task.done label { text-decoration: line-through;}
最終程式碼
現在我們的申請已經完成。 它可以正常工作,UI程式碼與業務邏輯程式碼完全隔離,可以測試,所有程式碼都在150行以下(包括模板!)。
供參考,這是最終程式碼:
.todo-app { width: 300px; margin: 50px auto; background: aliceblue; padding: 10px;}.todo-app > input { display: block; margin: auto;}.task-list { margin-top: 8px;}.task { font-size: 18px; color: #111111; display: grid; grid-template-columns: 30px auto 30px;}.task:hover { background-color: #def0ff;}.task > input { margin: auto;}.delete { opacity: 0; cursor: pointer; text-align: center;}.task:hover .delete { opacity: 1;}.task.done { opacity: 0.7;}.task.done label { text-decoration: line-through;}.task-panel { color: #0088ff; margin-top: 8px; font-size: 14px; display: flex;}.task-panel .task-counter { flex-grow: 1;}.task-panel span { padding: 5px; cursor: pointer;}.task-panel span.active { font-weight: bold;}