Skip to content
Mithril.js 2
Main Navigation 指南API

繁體中文

English
简体中文
Español
Français
Русский
Português – Brasil
Deutsch
日本語
한국어
Italiano
Polski
Türkçe
čeština
magyar

繁體中文

English
简体中文
Español
Français
Русский
Português – Brasil
Deutsch
日本語
한국어
Italiano
Polski
Türkçe
čeština
magyar

外觀

Sidebar Navigation

入門

安裝

簡單應用程式

資源

JSX

在舊版瀏覽器上使用 ES6+

動畫

測試

範例集

第三方整合

路徑處理

關鍵概念

虛擬 DOM 節點

組件

生命周期方法

Keys

自動重繪系統

雜項

框架比較

從 v1.x 遷移

從 v0.2.x 遷移

API

本頁導覽

Keys ​

什麼是 Keys? ​

Keys 代表被追蹤的識別身分。您可以透過特殊的 key 屬性將它們新增到 元素、元件和片段 vnode 中,使用方式如下:

javascript
m('.user', { key: user.id }, [
  /* ... */
]);

它們在以下情境中很有用:

  • 當您渲染模型資料或其他有狀態的資料時,您需要 Keys 來保持本地狀態與正確的子樹綁定。
  • 當您使用 CSS 獨立地為多個相鄰節點實現動畫效果,並且可以單獨移除其中任何一個節點時,您需要 Keys 來確保動畫與元素保持一致,並且不會意外地跳轉到其他節點。
  • 當您需要透過指令重新初始化子樹時,您需要新增一個 Key,然後在想要重新初始化它時更改它並重新繪製。

Keys 的限制 ​

重要: 對於所有片段,其子節點必須僅包含帶有 key 屬性的 vnode (keyed 片段),或僅包含沒有 key 屬性的 vnode (unkeyed 片段)。Key 屬性只能存在於首先支援屬性的 vnode 上,即 元素、元件和片段 vnode。其他 vnode,例如 null、undefined 和字串,不能有任何類型的屬性,因此它們不能有 key 屬性,因此不能在 keyed 片段中使用。

這表示像 [m(".foo", {key: 1}), null] 和 ["foo", m(".bar", {key: 2})] 這樣的寫法將無法工作,但 [m(".foo", {key: 1}), m(".bar", {key: 2})] 和 [m(".foo"), null] 可以。如果您忘記了這一點,您將收到一個非常有用的錯誤訊息來解釋這一點。

連結視圖列表中的模型資料 ​

當您渲染列表時,特別是可編輯的列表,您通常會處理像是可編輯的 TODO 等項目。這些項目具有狀態和身份,您必須為 Mithril.js 提供追蹤它們所需的資訊。

假設我們有一個簡單的社交媒體貼文列表,您可以在其中對貼文進行評論,並且可以隱藏貼文,例如舉報它們。

javascript
// `User` 和 `ComposeWindow` 為簡潔故省略
function CommentCompose() {
  return {
    view: function (vnode) {
      var post = vnode.attrs.post;
      return m(ComposeWindow, {
        placeholder: '撰寫您的評論...',
        submit: function (text) {
          return Model.addComment(post, text);
        },
      });
    },
  };
}

function Comment() {
  return {
    view: function (vnode) {
      var comment = vnode.attrs.comment;
      return m(
        '.comment',
        m(User, { user: comment.user }),
        m('.comment-body', comment.text),
        m(
          'a.comment-hide',
          {
            onclick: function () {
              Model.hideComment(comment).then(m.redraw);
            },
          },
          "I don't like this"
        )
      );
    },
  };
}

function PostCompose() {
  return {
    view: function (vnode) {
      var comment = vnode.attrs.comment;
      return m(ComposeWindow, {
        placeholder: '撰寫您的貼文...',
        submit: Model.createPost,
      });
    },
  };
}

function Post(vnode) {
  var showComments = false;
  var commentsFetched = false;

  return {
    view: function (vnode) {
      var post = vnode.attrs.post;
      var comments = showComments ? Model.getComments(post) : null;
      return m(
        '.post',
        m(User, { user: post.user }),
        m('.post-body', post.text),
        m(
          '.post-meta',
          m(
            'a.post-comment-count',
            {
              onclick: function () {
                if (!showComments && !commentsFetched) {
                  commentsFetched = true;
                  Model.fetchComments(post).then(m.redraw);
                }
                showComments = !showComments;
              },
            },
            post.commentCount,
            ' 則留言',
            post.commentCount === 1 ? '' : 's'
          ),
          m(
            'a.post-hide',
            {
              onclick: function () {
                Model.hidePost(post).then(m.redraw);
              },
            },
            "I don't like this"
          )
        ),
        showComments
          ? m(
              '.post-comments',
              comments == null
                ? m('.comment-list-loading', 'Loading...')
                : [
                    m(
                      '.comment-list',
                      comments.map(function (comment) {
                        return m(Comment, { comment: comment });
                      })
                    ),
                    m(CommentCompose, { post: post }),
                  ]
            )
          : null
      );
    },
  };
}

function Feed() {
  Model.fetchPosts().then(m.redraw);
  return {
    view: function () {
      var posts = Model.getPosts();
      return m(
        '.feed',
        m('h1', 'Feed'),
        posts == null
          ? m('.post-list-loading', 'Loading...')
          : m(
              '.post-view',
              m(PostCompose),
              m(
                '.post-list',
                posts.map(function (post) {
                  return m(Post, { post: post });
                })
              )
            )
      );
    },
  };
}

如您所見,它封裝了許多功能,但我想聚焦在兩件事上:

javascript
// 在 `Feed` 元件中
m(
  '.post-list',
  posts.map(function (post) {
    return m(Post, { post: post });
  })
);

// 在 `Post` 元件中
m(
  '.comment-list',
  comments.map(function (comment) {
    return m(Comment, { comment: comment });
  })
);

這些都指向 Mithril.js 一無所知的相關狀態的子樹 (Mithril.js 只知道 vnode)。當您不使用鍵 (Key) 時,可能會發生奇怪且無法預期的狀況。在這種情況下,嘗試點擊「N comment(s)」以顯示留言,在底部的留言撰寫框中輸入內容,然後點擊上面貼文上的「I don't like this」。這是一個線上示範,您可以試用,其中包含一個模擬模型 (請注意:如果您使用 Edge 或 IE,可能會因為連結的雜湊長度而遇到問題)。

它並未如預期運作,反而變得非常混亂並產生錯誤:它關閉了您已開啟的留言列表,且在您開啟留言的貼文之後的貼文,現在持續顯示「Loading...」,即使它認為已經載入了留言。這是因為留言是延遲載入的,它們假設每次都傳遞相同的留言 (這聽起來還算合理),但在這種情況下並非如此。這是因為 Mithril.js 修補未設定鍵 (Key) 的片段的方式:它以迭代方式逐個修補它們。所以在這種情況下,差異比對 (diff) 可能如下所示:

  • 之前:A, B, C, D, E
  • 已修補:A, B, C -> D, D -> E, E -> (已移除)

並且由於元件保持不變(它始終是 Comment),因此只有屬性會更改,而不會被替換。

要修正此錯誤,您只需新增一個鍵 (Key),讓 Mithril.js 知道在必要時移動狀態以解決問題。這是一個已修正所有內容,且可運作的線上範例。

javascript
// In the `Feed` component
m(
  '.post-list',
  posts.map(function (post) {
    return m(Post, { key: post.id, post: post });
  })
);

// In the `Post` component
m(
  '.comment-list',
  comments.map(function (comment) {
    return m(Comment, { key: comment.id, comment: comment });
  })
);

請注意,對於留言,雖然在這種情況下技術上來說不使用鍵也能運作,但如果您新增任何類似巢狀留言或編輯它們的功能,它也會以類似的方式崩潰,並且您必須為它們新增鍵。

保持動畫物件集合的順暢性 ​

在某些情況下,您可能想要為列表、方塊和類似物件添加動畫。 讓我們從這個簡單的程式碼開始:

javascript
var colors = ['red', 'yellow', 'blue', 'gray'];
var counter = 0;

function getColor() {
  var color = colors[counter];
  counter = (counter + 1) % colors.length;
  return color;
}

function Boxes() {
  var boxes = [];

  function add() {
    boxes.push({ color: getColor() });
  }

  function remove(box) {
    var index = boxes.indexOf(box);
    boxes.splice(index, 1);
  }

  return {
    view: function () {
      return [
        m('button', { onclick: add }, 'Add box, click box to remove'),
        m(
          '.container',
          boxes.map(function (box, i) {
            return m(
              '.box',
              {
                'data-color': box.color,
                onclick: function () {
                  remove(box);
                },
              },
              m('.stretch')
            );
          })
        ),
      ];
    },
  };
}

這段程式碼看似簡單,不過試試看這個線上範例。 在該範例中,點擊以建立幾個方塊,選取一個方塊,然後追蹤其大小。 我們希望大小和旋轉與方塊(用顏色表示)相連,而不是與網格中的位置相關。 您會發現,方塊大小會突然跳變,但它會隨著位置保持不變。 這表示我們需要為它們指定鍵值 (key)。

在這種情況下,給它們唯一的鍵值 (key) 是相對簡單的:只需建立一個計數器,每次讀取它時都會遞增。

javascript
var colors = ['red', 'yellow', 'blue', 'gray'];
var counter = 0;

function getColor() {
  var color = colors[counter];
  counter = (counter + 1) % colors.length;
  return color;
}

function Boxes() {
  var boxes = [];
  var nextKey = 0;

  function add() {
    boxes.push({ color: getColor() }); 
    var key = nextKey; 
    nextKey++; 
    boxes.push({ key: key, color: getColor() }); 
  }

  function remove(box) {
    var index = boxes.indexOf(box);
    boxes.splice(index, 1);
  }

  return {
    view: function () {
      return [
        m('button', { onclick: add }, 'Add box, click box to remove'),
        m(
          '.container',
          boxes.map(function (box, i) {
            return m(
              '.box',
              {
                key: box.key, 
                'data-color': box.color,
                onclick: function () {
                  remove(box);
                },
              },
              m('.stretch')
            );
          })
        ),
      ];
    },
  };
}

這是修正後的示範,供您試用,以了解它的運作方式有何不同。

使用單子鍵值 (key) 片段重新初始化視圖 ​

當您在模型等處裡有狀態的實體時,使用鍵值 (key) 渲染模型視圖通常很有用。 假設您有以下版面配置:

javascript
function Layout() {
  // ...
}

function Person() {
  // ...
}

m.route(rootElem, '/', {
  '/': Home,
  '/person/:id': {
    render: function () {
      return m(Layout, m(Person, { id: m.route.param('id') }));
    },
  },
  // ...
});

您的 Person 元件可能如下所示:

javascript
function Person(vnode) {
  var personId = vnode.attrs.id;
  var state = 'pending';
  var person, error;

  m.request('/api/person/:id', { params: { id: personId } }).then(
    function (p) {
      person = p;
      state = 'ready';
    },
    function (e) {
      error = e;
      state = 'error';
    }
  );

  return {
    view: function () {
      if (state === 'pending') return m(LoadingIcon);
      if (state === 'error') {
        return error.code === 404
          ? m('.person-missing', 'Person not found.')
          : m('.person-error', 'An error occurred. Please try again later');
      }
      return m(
        '.person',
        m(
          m.route.Link,
          {
            class: 'person-edit',
            href: '/person/:id/edit',
            params: { id: personId },
          },
          'Edit'
        ),
        m('.person-name', 'Name: ', person.name)
        // ...
      );
    },
  };
}

假設您新增了一種方法來從此元件連結到其他人,例如新增一個 "manager" 欄位。

javascript
function Person(vnode) {
  // ...

  return {
    view: function () {
      // ...
      return m(
        '.person',
        m(
          m.route.Link,
          {
            class: 'person-edit',
            href: '/person/:id/edit',
            params: { id: personId },
          },
          'Edit'
        ),
        m('.person-name', person.name),
        // ...
        m(
          '.manager',
          'Manager: ',
          m(
            m.route.Link,
            {
              href: '/person/:id',
              params: { id: person.manager.id },
            },
            person.manager.name
          )
        )
        // ...
      );
    },
  };
}

假設該人員的 ID 是 1,而經理的 ID 是 2,您將從 /person/1 切換到 /person/2,並保留在同一路由上。 但是由於您使用了路由解析器 render 方法,因此樹狀結構被保留,您只是從 m(Layout, m(Person, {id: "1"})) 變更為 m(Layout, m(Person, {id: "2"}))。 在此,Person 沒有變更,因此它不會重新初始化元件。 但對於我們的情況來說,這是不可取的,因為這意味著沒有取得新使用者資料。 這就是鍵值 (key) 發揮作用的地方。 我們可以將路由解析器修改為以下內容來修正它:

javascript
m.route(rootElem, '/', {
  '/': Home,
  '/person/:id': {
    render: function () {
      return m(
        Layout,
        // 將其放入陣列中,以防我們稍後新增其他元素。
        // 請記住:片段必須僅包含帶鍵值 (key) 的子元素或不包含帶鍵值 (key) 的子元素。
        [m(Person, { id: m.route.param('id'), key: m.route.param('id') })]
      );
    },
  },
  // ...
});

常見陷阱 ​

人們在使用鍵時常會遇到一些常見的陷阱。以下列出一些,以幫助你理解它們為何無法正常運作。

包裹帶有 key 的元素 ​

以下兩個程式碼片段的運作方式不同:

javascript
users.map(function (user) {
  return m('.wrapper', [m(User, { user: user, key: user.id })]);
});

users.map(function (user) {
  return m('.wrapper', { key: user.id }, [m(User, { user: user })]);
});

第一段程式碼將 key 綁定到 User 元件,但 users.map(...) 建立的外部片段則完全沒有鍵。以這種方式包裹帶有 key 的元素是無效的,可能導致每次列表更改時發出額外的請求,或是內部表單輸入遺失其狀態。產生的行為類似於文章列表的錯誤範例,但不會有狀態損壞的問題。

第二段程式碼將 key 綁定到 .wrapper 元素,確保外部片段_有_ key。這能達成你可能一直想做的事情,並且移除使用者不會對其他使用者實例的狀態造成任何問題。

將 key 放在元件內部 ​

假設在人員範例中,你改為這樣做:

javascript
// 避免
function Person(vnode) {
  var personId = vnode.attrs.id;
  // ...

  return {
    view: function () {
      return m.fragment(
        { key: personId }
        // 你先前在 view 中擁有的內容
      );
    },
  };
}

這樣做無效,因為 key 不適用於整個元件。它僅適用於 view,因此你不會像希望的那樣重新獲取資料。

建議採用範例中的解決方案,將 key 放在_使用_元件的 vnode 中,而不是元件內部。

javascript
// 建議
return [m(Person, { id: m.route.param('id'), key: m.route.param('id') })];

不必要地使用 key ​

常見的誤解是 key 本身帶有識別碼。Mithril.js 強制要求所有片段的子元素必須全部都有 key,或者全部都沒有,否則會拋出錯誤。假設你有以下佈局:

javascript
m('.page', m('.header', { key: 'header' }), m('.body'), m('.footer'));

這顯然會拋出錯誤,因為 .header 有 key,而 .body 和 .footer 都沒有 key。重點是,你不需要為此使用 key。如果你發現自己為這類事物使用鍵,解決方案不是新增鍵,而是移除它們。只有在你真的、_真的_需要它們時才新增它們。是的,底層 DOM 節點具有識別碼,但 Mithril.js 不需要追蹤這些識別碼來正確修補它們。它實際上從不需要這樣做。只有在列表中每個條目都有關聯的狀態,且 Mithril.js 本身沒有追蹤這些狀態(無論是在模型、元件還是 DOM 本身中),才需要使用 key。

最後一件事:避免靜態 key。它們總是沒有必要的。如果你沒有計算你的 key 屬性,你可能做錯了什麼。

請注意,如果真的需要單獨使用帶有 key 的元素,請使用單子帶有 key 的片段。它只是一個帶有單個子元素的陣列,該子元素是一個帶有 key 的元素,例如 [m("div", {key: foo})]。

混合 key 類型 ​

Key 被讀取為物件屬性名稱。這意味著 1 和 "1" 被視為相同。為了避免不必要的麻煩,請盡量不要混合 key 類型。如果你這樣做,你可能會遇到重複的 key 和意外的行為。

javascript
// 避免
var things = [
  { id: '1', name: 'Book' },
  { id: 1, name: 'Cup' },
];

如果你絕對必須這樣做,並且你無法控制它,請使用一個表示其類型的前綴,以便它們保持區別。

javascript
things.map(function (thing) {
  return m(
    '.thing',
    { key: typeof thing.id + ':' + thing.id }
    // ...
  );
});

使用空洞隱藏帶有 key 的元素 ​

像 null、undefined 和布林值等空值會被視為沒有 key 的 vnode,因此以下程式碼無法正常運作:

javascript
// 避免
things.map(function (thing) {
  return shouldShowThing(thing)
    ? m(Thing, { key: thing.id, thing: thing })
    : null;
});

相反,在返回列表之前過濾它,Mithril.js 會做正確的事情。大多數時候,Array.prototype.filter 正是你需要的,建議優先使用。

javascript
// 建議
things
  .filter(function (thing) {
    return shouldShowThing(thing);
  })
  .map(function (thing) {
    return m(Thing, { key: thing.id, thing: thing });
  });

重複的 key ​

片段項目的 key _必須_是唯一的,否則會造成混淆,無法確定哪個 key 應該對應哪個元素。你也可能遇到元素無法像預期那樣移動的問題。

javascript
// 避免
var things = [
  { id: '1', name: 'Book' },
  { id: '1', name: 'Cup' },
];

Mithril.js 使用一個空物件將 key 映射到索引,以了解如何正確修補帶有 key 的片段。當存在重複的 key 時,Mithril.js 無法判斷元素應該移動到哪裡,因此可能會崩潰,或在更新時產生非預期的行為,尤其是在列表內容發生變更時。Mithril.js 需要不同的 key 才能正確地將舊節點連接到新節點,因此你必須選擇一些在本地唯一的事物作為 key。

使用物件作為 key ​

片段項目的 key 被視為屬性 key。這樣的東西不會如你所想的那樣運作。

javascript
// 避免
things.map(function (thing) {
  return m(Thing, { key: thing, thing: thing });
});

如果物件具有 toString 方法,則會被調用,且結果將取決於該方法的回傳值,即使您可能沒有意識到該方法正在被調用。如果沒有 toString 方法,所有物件都會被轉換為字串 "[object Object]",導致重複 key的問題。

Pager
上一頁生命周期方法
下一頁自動重繪系統

以 MIT 授權條款 發布。

版權所有 (c) 2024 Mithril Contributors

https://mithril.js.org/keys.html

以 MIT 授權條款 發布。

版權所有 (c) 2024 Mithril Contributors