2022/03/28

Vue Router

以下為 Vue Router 的一個例子,Vue Router 的用途,是能夠在單一網頁頁面中,在 browser 不重新載入到新的 url 的狀況下,能夠增加 url history 並調整頁面內容狀態的功能,也就是能夠實現 SPA (single page application) 的功能。

傳統的網頁,會向 web application server 要求打開一個 url 網址,server 會回傳整個網頁的 html 內容。後來為了在不轉向到新的 url 的條件下,並調整網頁的內容,就發生了 web service,server 會從某個網址回傳 XML 或 JSON,網頁透過 AJAX 方式提取資料更新網頁內容。SPA 是更進一步,可在不重新發出新的 url request 到 server 的條件下,更新網頁內容並增加 url 瀏覽歷程,也就是增加了單一網頁頁面的顯示狀態。

Vue router 實例:

<!DOCTYPE html>
<html lang="en">
<head>
  <!-- <script src="https://unpkg.com/vue@3.2.10"></script> -->
  <!-- <script src="https://unpkg.com/vue@3.2.10/dist/vue.global.js"></script> -->
  <script src="https://unpkg.com/vue@3.2.10/dist/vue.global.prod.js"></script>
  <!-- <script src="https://unpkg.com/vue-router@4.0.11"></script> -->
  <!-- <script src="https://unpkg.com/vue-router@4.0.11/dist/vue.global.js"></script> -->
  <script src="https://unpkg.com/vue-router@4.0.11/dist/vue-router.global.prod.js"></script>
<!--
  <script src="https://unpkg.com/vue/dist/vue.js"></script>
  <script src="https://unpkg.com/vue-router/dist/vue-router.js"></script> -->

</head>

<body>

<div id="app">
  <h1>Hello App!</h1>
  <p>
    <!-- use the router-link component for navigation. -->
    <!-- specify the link by passing the `to` prop. -->
    <!-- `<router-link>` will render an `<a>` tag with the correct `href` attribute -->
    <router-link to="/">Go to Home</router-link>
    <router-link to="/foo">Go to Foo</router-link>
    <router-link to="/bar">Go to Bar</router-link>
  </p>
  <!-- route outlet -->
  <!-- component matched by the route will render here -->
  <router-view></router-view>
</div>

<script>
// 1. Define route components.
// These can be imported from other files
const Home = { template: '<div>Home</div>' }
const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }

// 2. Define some routes
// Each route should map to a component.
// We'll talk about nested routes later.
const routes = [
  { path: '/', component: Home },
  { path: '/foo', component: Foo },
  { path: '/bar', component: Bar }
]

// 3. Create the router instance and pass the `routes` option
// You can pass in additional options here, but let's
// keep it simple for now.
const router = VueRouter.createRouter({
  // 4. Provide the history implementation to use. We are using the hash history for simplicity here.
  history: VueRouter.createWebHashHistory(),
  routes, // short for `routes: routes
})

// 5. Create and mount the root instance.
const app = Vue.createApp({})
// Make sure to _use_ the router instance to make the
// whole app router-aware.
app.use(router)

app.mount('#app')

// Now the app has started!
</script>
</body>

</html>

以下是點擊了 foo 以後,網頁的 DOM 產生的資料。 foo 的部分,會自動加上這兩個 css class class ="router-link-exact-active router-link-active"

<div id="app" data-v-app="">
   <h1>Hello App!</h1>
   <p>
     <a href="#/" class="">Go to Home</a>
     <a href="#/foo" class="router-link-active router-link-exact-active" aria-current="page">Go to Foo</a>
     <a href="#/bar" class="">Go to Bar</a>
   </p>
   <div>foo</div>
</div>

<router-link> 相關屬性

to

<!-- 直接填寫文字字串 -->
<router-link to="/home">Home</router-link>
<!-- 結果 -->
<a href="/home">Home</a>


<!-- 使用 v-bind,省略 path -->
<router-link v-bind:to="'/home'">Home</router-link>

<!-- 不寫 v-bind 也可以,就像綁定別的屬性一樣 -->
<router-link :to="'/home'">Home</router-link>

<!-- 同上 -->
<router-link :to="{ path: '/home' }">Home</router-link>

<!-- 命名的路由 -->
<router-link :to="{ name: 'user', params: { userId: 123 }}">User</router-link>

<!-- 帶查詢參數,下面的結果為 /register?plan=private -->
<router-link :to="{ path: '/register', query: { plan: 'private' }}">Register</router-link>

replace

如果不希望在 browser 留下 url history,可加上 replace

<router-link to="/home" replace>Home</router-link>

當點擊時,會呼叫 router.replace() 而不是 router.push()

active-class

設定當 link 啟用時,DOM 節點使用的 css class

<style>
   ._active{
      background-color : red;
   }
</style>
<p>
   <router-link v-bind:to = "{ path: '/route1'}" active-class = "_active">Router Link 1</router-link>
   <router-link v-bind:to = "{ path: '/route2'}">Router Link 2</router-link>
</p>

aria-current-value

預設為 "page",可能的值

'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' (string)

當 link 為 active 時,傳送給 aria-current 屬性的值

custom

預設為 "false"

決定 <router-link> 是不是"不要"產生到 <a> 裡面。如果使用 v-slot 產生 custom router link,預設要包裝在 <a> 裡面,如果增加 custom 屬性,就取消這個限制

<router-link to="/home" custom v-slot="{ navigate, href, route }">
  <a :href="href" @click="navigate">{{ route.fullPath }}</a>
</router-link>

會 render 為

<a href="/home">/home</a>
<router-link to="/home" v-slot="{ route }">
  <span>{{ route.fullPath }}</span>
</router-link>

會 render 為

<a href="/home"><span>/home</span></a>

exact-active-class

當 link 被精確匹配時,要啟用的 css class

<router-link> 的 v-slot

可產生自訂的 html tag,記得一定要加上 custom

ex:

<router-link
  to="/foo"
  custom
  v-slot="{ href, route, navigate, isActive, isExactActive }"
>
  <NavLink :active="isActive" :href="href" @click="navigate">
    {{ route.fullPath }}
  </NavLink>
</router-link>
  • href: resolved url,類似 a tag 的 href
  • route: resolved normalized location
  • navigate: 驅動 navigation 的 function,必要時,會自動 prevent events
  • isActive: 如果被 apply active class ,就會是 true
  • isExactActive: 如果被 apply exact active class,就會是 true

會 render 為

<navlink active="true" href="#/foo">/foo</navlink>

ex:

    <ul>
      <router-link
        to="/foo"
        custom
        v-slot="{ href, route, navigate, isActive, isExactActive }"
      >
        <li
          :class="[isActive && 'router-link-active', isExactActive && 'router-link-exact-active']"
        >
          <a :href="href" @click="navigate">{{ route.fullPath }}</a>
        </li>
      </router-link>
    </ul>

會 render 為

<ul>
  <li class="router-link-active router-link-exact-active"><a href="#/foo">/foo</a></li>
</ul>

Dynamic Route Matching

Vue 需要將某個 pattern 對應到一個 component 的方法,例如不同 userid 的 user 資料

$route.params 可用來協助對應 route 的 pattern 上的參數

const User = {
  template: '<div>User {{ $route.params.id }}</div>',
}

// these are passed to `createRouter`
const routes = [
  // dynamic segments start with a colon
  { path: '/users/:id', component: User },
]
pattern matching $route.params
/users/:id /users/john { id: 'john' }
/users/:id/posts/:postid /users/john/posts/123 { id: 'john', posted: '123' }

Reacting to Params Changes

因為 /users/john/users/mary 這兩個路徑會使用同一個 component,比較有效率的方法,是不重建 component,直接更新內容,但這樣也不會呼叫到 component 的 lifecycle hooks

const User = {
  template: '<div>User {{ $route.params.id }}</div>',
  created() {
    this.$watch(
      () => this.$route.params,
      (toParams, previousParams) => {
        // react to route changes...
        console.log("created toParams=", toParams, ", previousParams=",previousParams);
      }
    )
  },
  // navigation guard,可在這邊檢查並取消 navigation
  async beforeRouteUpdate(to, from) {
    // react to route changes...
    // this.userData = await fetchUser(to.params.id)
    console.log("beforeRouteUpdate to=", to, ", from=",from);
    this.userData = to.params.id
  },
}

Catch /404 Not Found Route

const routes = [
  // will match everything and put it under `$route.params.pathMatch`
  { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
  // will match anything starting with `/user-` and put it under `$route.params.id`
  { path: '/user-:id(.*)', component: User },
]

如果直接在 brwoser 網址連結到

1-2-param.html#/not

就會是用 NotFound 這個 Component 處理

如果直接在 brwoser 網址連結到

1-2-param.html#/user-test/12345

會收到 $route.params.id 的值為 test/12345

Routes' Matching Syntax

大部分的 application 使用 static route 以及類似 /users/:userId 這樣的 route

custom regex

如果要在 url 分辨 orderId 及 productName,最簡單的方法就是加上 /o /p 靜態的部分用來區別

const routes = [
  // matches /o/3549
  { path: '/o/:orderId' },
  // matches /p/books
  { path: '/p/:productName' },
]

如果 orderId 跟 productName 可用數字/字串區分,可將 route 改為

const routes = [
  // /:orderId -> matches only numbers
  { path: '/:orderId(\\d+)' },
  // /:productName -> matches anything else
  { path: '/:productName' },
]

repeatable params

如果是 /first/second/third 這樣的 route

* 為 0 or more, + 為 1 or more

const routes = [
  // /:chapters -> matches /one, /one/two, /one/two/three, etc
  { path: '/:chapters+' },
  // /:chapters -> matches /, /one, /one/two, /one/two/three, etc
  { path: '/:chapters*' },
]

可用以下方式,將 array 參數轉換為 path

// given { path: '/:chapters*', name: 'chapters' },
router.resolve({ name: 'chapters', params: { chapters: [] } }).href
// produces /
router.resolve({ name: 'chapters', params: { chapters: ['a', 'b'] } }).href
// produces /a/b

// given { path: '/:chapters+', name: 'chapters' },
router.resolve({ name: 'chapters', params: { chapters: [] } }).href
// throws an Error because `chapters` is empty
const routes = [
  // only match numbers
  // matches /1, /1/2, etc
  { path: '/:chapters(\\d+)+' },
  // matches /, /1, /1/2, etc
  { path: '/:chapters(\\d+)*' },
]

optional param

? 代表 0 or 1

const routes = [
  // will match /users and /users/posva
  { path: '/users/:userId?' },
  // will match /users and /users/42
  { path: '/users/:userId(\\d+)?' },
]

Nested Routes

在 application 裡面常見到 nested components

ex:

/user/johnny/profile                  /user/johnny/posts
+------------------+                  +-----------------+
| User             |                  | User            |
| +--------------+ |                  | +-------------+ |
| | Profile      | |  +------------>  | | Posts       | |
| |              | |                  | |             | |
| +--------------+ |                  | +-------------+ |
+------------------+                  +-----------------+
<div id="app">
 <h1>Nested Views</h1>
  <p>
    <router-link to="/users/eduardo">/users/eduardo</router-link>
    <br />
    <router-link to="/users/eduardo/profile"
      >/users/eduardo/profile</router-link
    >
    <br />
    <router-link to="/users/eduardo/posts">/users/eduardo/posts</router-link>
  </p>
  <router-view></router-view>
</div>

<script>

const User = {
  template: '<h2>User {{ $route.params.username }}</h2><router-view></router-view>',
}
const UserHome = {
  template: '<div>Home</div>',
}
const UserProfile = {
  template: '<div>UserProfile</div>',
}
const UserPosts = {
  template: '<div>UserPosts</div>',
}

const routes = [
  {
    path: '/users/:username',
    component: User,
    children: [
      // UserHome will be rendered inside User's <router-view>
      // when /users/:username is matched
      { path: '', component: UserHome },

      // UserProfile will be rendered inside User's <router-view>
      // when /users/:username/profile is matched
      { path: 'profile', component: UserProfile },

      // UserPosts will be rendered inside User's <router-view>
      // when /users/:username/posts is matched
      { path: 'posts', component: UserPosts },
    ],
  }
]

const router = VueRouter.createRouter({
  history: VueRouter.createWebHashHistory(),
  routes,
})

const app = Vue.createApp({})
app.use(router)

app.mount('#app')

</script>

Programmatic Navigation

除了用 <router-link> 產生 url anchor tags 以外,也可以用程式處理

在 vue application 中,可用 $router 為 route instance,故要呼叫 this.$router.push

<router-link :to="..."> 就等同於 router.push(...)

// literal string path
router.push('/users/eduardo')

// object with path
router.push({ path: '/users/eduardo' })

// named route with params to let the router build the url
router.push({ name: 'user', params: { username: 'eduardo' } })

// 如果有 path,就會忽略 params
// with query, resulting in /register?plan=private
router.push({ path: '/register', query: { plan: 'private' } })

// with hash, resulting in /about#team
router.push({ path: '/about', hash: '#team' })

可用 uername 變數

const username = 'eduardo'
// we can manually build the url but we will have to handle the encoding ourselves
router.push(`/user/${username}`) // -> /user/eduardo
// same as
router.push({ path: `/user/${username}` }) // -> /user/eduardo
// if possible use `name` and `params` to benefit from automatic URL encoding
router.push({ name: 'user', params: { username } }) // -> /user/eduardo
// `params` cannot be used alongside `path`
router.push({ path: '/user', params: { username } }) // -> /user

replace current location

切換網址,但不會 push 到 history

<router-link :to="..." replace> 等同 router.replace(...)

router.push({ path: '/home', replace: true })
// equivalent to
router.replace({ path: '/home' })

history

// go forward by one record, the same as router.forward()
router.go(1)

// go back by one record, the same as router.back()
router.go(-1)

// go forward by 3 records
router.go(3)

// fails silently if there aren't that many records
router.go(-100)
router.go(100)

named route

為 route 命名,優點:

  • 沒有 hardcoded url
  • 可自動 encode/decode params
  • 填寫 url 不會發生 typo
  • bypass path ranking
const routes = [
  {
    path: '/user/:username',
    name: 'user',
    component: User
  }
]

用以下方式使用 named route

<router-link :to="{ name: 'user', params: { username: 'erina' }}">
  User
</router-link>

router.push({ name: 'user', params: { username: 'erina' } })

named views

有時候,需要一次顯示多個 views,但不是 nested view

/settings/emails                                       /settings/profile
+-----------------------------------+                  +------------------------------+
| UserSettings                      |                  | UserSettings                 |
| +-----+-------------------------+ |                  | +-----+--------------------+ |
| | Nav | UserEmailsSubscriptions | |  +------------>  | | Nav | UserProfile        | |
| |     +-------------------------+ |                  | |     +--------------------+ |
| |     |                         | |                  | |     | UserProfilePreview | |
| +-----+-------------------------+ |                  | +-----+--------------------+ |
+-----------------------------------+                  +------------------------------+
<div id="app">
  <h1>Nested Named Views</h1>
  <router-view></router-view>
</div>

<script>

const About = {
  template: '<h1>About</h1>',
}
const Home = {
  template: '<div>Home</div>',
}
const UserEmailsSubscriptions = {
  template: '<div><h3>Email Subscriptions</h3></div>',
}
const UserProfile = {
  template: '<div><h3>UserProfile</h3></div>',
}
const UserProfilePreview = {
  template: '<div><h3>UserProfilePreview</h3></div>',
}


const UserSettingsNavTemplate = `
  <div class="us__nav">
    <router-link to="/settings/emails">emails</router-link>
    <br />
    <router-link to="/settings/profile">profile</router-link>
  </div>
  `
// const UserSettingsNav = {
//   template: UserSettingsNavTemplate
// }

const UserSettingsTemplate = `
  <div class="us">
    <h2>User Settings</h2>
    <user-settings-nav />
    <router-view class="us__content" />
    <router-view name="helper" class="us__content us__content--helper" />
  </div>
  `
const UserSettings = {
  template: UserSettingsTemplate
}

const routes = [
    {
      path: '/settings',
      // You could also have named views at tho top
      component: UserSettings,
      children: [
        {
          path: 'emails',
          component: UserEmailsSubscriptions,
        },
        {
          path: 'profile',
          components: {
            default: UserProfile,
            helper: UserProfilePreview,
          },
        },
      ],
    },
  ]

const router = VueRouter.createRouter({
  history: VueRouter.createWebHashHistory(),
  routes,
})

const app = Vue.createApp({})
app.use(router)

app.component('user-settings-nav', {
  template: UserSettingsNavTemplate
})

app.mount('#app')

</script>

Redirect and Alias

直接在 routes 做 redirect

const routes = [{ path: '/home', redirect: '/' }]

const routes = [{ path: '/home', redirect: { name: 'homepage' } }]

用 redirect function 做 dynamic redirect

const routes = [
  {
    // /search/screens -> /search?q=screens
    path: '/search/:searchText',
    redirect: to => {
      // the function receives the target route as the argument
      // we return a redirect path/location here.
      return { path: '/search', query: { q: to.params.searchText } }
    },
  },
  {
    path: '/search',
    // ...
  },
]

可 redirect 到相對路徑

const routes = [
  {
    // will always redirect /users/123/posts to /users/123/profile
    path: '/users/:id/posts',
    redirect: to => {
      // the function receives the target route as the argument
      // a relative location doesn't start with `/`
      // or { path: 'profile'}
      return 'profile'
    },
  },
]

alias

/ alias 為 /home ,代表瀏覽 /home/ 都是一樣的 component

const routes = [{ path: '/', component: Homepage, alias: '/home' }]

在 nested view 也可以用

const routes = [
  {
    path: '/users',
    component: UsersLayout,
    children: [
      // this will render the UserList for these 3 URLs
      // - /users
      // - /users/list
      // - /people
      { path: '', component: UserList, alias: ['/people', 'list'] },
    ],
  },
]

如果有參數

const routes = [
  {
    path: '/users/:id',
    component: UsersByIdLayout,
    children: [
      // this will render the UserDetails for these 3 URLs
      // - /users/24
      // - /users/24/profile
      // - /24
      { path: 'profile', component: UserDetails, alias: ['/:id', ''] },
    ],
  },
]

Passing Props to Route Components

在 component 使用 $route 時,會跟 route 綁定在一起。可利用 propsoption

可將以下語法

const User = {
  template: '<div>User {{ $route.params.id }}</div>'
}
const routes = [{ path: '/user/:id', component: User }]

替換為

const User = {
  // make sure to add a prop named exactly like the route param
  props: ['id'],
  template: '<div>User {{ id }}</div>'
}
const routes = [{ path: '/user/:id', component: User, props: true }]

Boolean mode

props 設定為 true,就表示 route.params 會指定為 component props

Named views

可為每一個 named view 定義 props

const routes = [
  {
    path: '/user/:id',
    components: { default: User, sidebar: Sidebar },
    props: { default: true, sidebar: false }
  }
]

Object mode

const routes = [
  {
    path: '/promotion/from-newsletter',
    component: Promotion,
    props: { newsletterPopup: false }
  }
]

Function mode

const routes = [
  {
    path: '/search',
    component: SearchUser,
    props: route => ({ query: route.query.q })
  }
]

URL /search?q=vue 會將 {query: 'vue'} 以 props 傳給 SearchUser component


History Mode

Hash Mode

在網址後面加上 #,不利於 search engine

import { createRouter, createWebHashHistory } from 'vue-router'

const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    //...
  ],
})

HTML5 Mode

import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    //...
  ],
})

在 url 會看到 https://example.com/user/id ,但實際上如果直接在 browser 對 server 發送 https://example.com/user/id 這個 request,會出現 404 Error

解決方式就是單純地將該網址用 index.html 服務

References

Vue router v4.x

Vue router v4.x API Reference

Vue.js 路由

Vue router 與前端路由管理

2022/03/21

Vue.js Essentials 3

Event Handling

Listening to Evnets

可使用 v-on directive (通常縮寫為 @)監聽 DOM events,並執行某些 js script

v-on:click="methodName"@click="methodName" 都可以

<div id="basic-event">
  <button @click="counter += 1">Add 1</button>
  <p>The button above has been clicked {{ counter }} times.</p>
</div>

<script>
Vue.createApp({
  data() {
    return {
      counter: 0
    }
  }
}).mount('#basic-event')
</script>

Method Event Handlers

如果 js logic 比較複雜,v-on 可改用 method 而不是 attribute

<div id="event-with-method">
  <!-- `greet` is the name of a method defined below -->
  <button @click="greet">Greet</button>
</div>

<script>
Vue.createApp({
  data() {
    return {
      name: 'Vue.js'
    }
  },
  methods: {
    greet(event) {
      // `this` inside methods points to the current active instance
      alert('Hello ' + this.name + '!')
      // `event` is the native DOM event
      if (event) {
        alert(event.target.tagName)
      }
    }
  }
}).mount('#event-with-method')
</script>

Methods in Inline Handlers

可在 inline js statement 使用 method

<div id="inline-handler">
  <button @click="say('hi')">Say hi</button>
  <button @click="say('what')">Say what</button>
</div>

<script>
Vue.createApp({
  methods: {
    say(message) {
      alert(message)
    }
  }
}).mount('#inline-handler')
</script>

如果需要使用原本的 DOM event,可用 $event 傳入

<button @click="warn('Form cannot be submitted yet.', $event)">
  Submit
</button>

<script>
Vue.createApp({
  methods: {
    warn(message, event) {
      // now we have access to the native event
      if (event) {
        event.preventDefault()
      }
      alert(message)
    }
  }
}).mount('#inline-handler')
</script>

Multiple Event Handlers

可用 , 區隔,同時使用多個 methods

<!-- both one() and two() will execute on button click -->
<button @click="one($event), two($event)">
  Submit
</button>

<script>
Vue.createApp({
  methods: {
    one(event) {
      // first handler logic...
    },
    two(event) {
      // second handler logic...
    }
  }
}).mount('#inline-handler')
</script>

Event Modifiers

在 event handler 裡面呼叫 event.preventDefault()event.stopPropagation() 很常見。vue 提供 v-on 使用的 event modifier

  • .stop
  • .prevent
  • .capture
  • .self
  • .once
  • .passive
<!-- the click event's propagation will be stopped -->
<a @click.stop="doThis"></a>

<!-- the submit event will no longer reload the page -->
<form @submit.prevent="onSubmit"></form>

<!-- modifiers can be chained -->
<a @click.stop.prevent="doThat"></a>

<!-- just the modifier -->
<form @submit.prevent></form>

<!-- use capture mode when adding the event listener -->
<!-- i.e. an event targeting an inner element is handled here before being handled by that element -->
<div @click.capture="doThis">...</div>

<!-- only trigger handler if event.target is the element itself -->
<!-- i.e. not from a child element -->
<div @click.self="doThat">...</div>

注意:順序很重要

@click.prevent.self 會停止所有 clicks

@click.self.prevent 只會停止 clicks on the element itself

<!-- the click event will be triggered at most once -->
<a @click.once="doThis"></a>

.once 可用在 component events,跟其他 modifier 不同。

vue 提供 .passive modifier,跟 addEventListenerpassive option 一樣

<!-- the scroll event's default behavior (scrolling) will happen -->
<!-- immediately, instead of waiting for `onScroll` to complete  -->
<!-- in case it contains `event.preventDefault()`                -->
<div @scroll.passive="onScroll">...</div>

.passive 在改善 mobile device 方面特別有用

注意:.passive.prevent 不能一起使用,因為 .prevent 會被忽略,造成 browser 發生 warning。 passive 會跟 browser 互動,不需要 prevent the event's default behavior

Key Modifiers

在監聽 keyboard events 時,常需要檢查 keys,vue 提供 v-on@ 使用 key modifier

<!-- only call `vm.submit()` when the `key` is `Enter` -->
<input @keyup.enter="submit" />

可使用 KeyboardEvent.key 定義的 key names,要轉為 kebab-case

<!-- 當 $event.key 為 PageDown 時,才會呼叫該 handler -->
<input @keyup.page-down="onPageDown" />

以下為常用的 key 的 aliases

  • .enter
  • .tab
  • .delete (captures both "Delete" and "Backspace" keys)
  • .esc
  • .space
  • .up
  • .down
  • .left
  • .right

System Modifier Keys

可限制某個 modifier key 被按下時,才會驅動 mouse/keyboard event listener

  • .ctrl
  • .alt
  • .shift
  • .meta 在 Mac 是 ⌘,在 Windows 是 ⊞,在 Sun Microsystems keyboard 是 ◆,在特殊的 MIT and Lisp machine keyboard (ex: Knight kryboard, space-cadetkeyboard) 是 META,在 Symbolics keyboards 是 META 或 Meta
<!-- Alt + Enter -->
<input @keyup.alt.enter="clear" />

<!-- Ctrl + Click -->
<div @click.ctrl="doSomething">Do something</div>

modifier keys 通常是用 keyup event,但 keyup.ctrl 只會在按下 ctrl 時被 trigger,不會在 release ctrl 時被 trigger

.exact modifier

.exact 可控制 system modifier 的 exact combination

<!-- this will fire even if Alt or Shift is also pressed -->
<button @click.ctrl="onClick">A</button>

<!-- this will only fire when Ctrl and no other keys are pressed -->
<button @click.ctrl.exact="onCtrlClick">A</button>

<!-- this will only fire when no system modifiers are pressed -->
<button @click.exact="onClick">A</button>

mouse button modifier

  • .left
  • .right
  • .middle

Why Listeners in HTML?

vue 的 event modifier 策略違反了傳統的 "separation of concerns" 規則,因為 handler function 與 expression 跟 ViewModel 綁定,這樣做的優點:

  1. 移除 HTML template 時,就移除了 handler function,很容易維護
  2. 因不需要在 js 手動綁定 event listener,ViewModel 的 code 可單純只有 logic 且為 DOM-free,很容易測試
  3. 當 ViewModel 被刪除時,所有 event listener 也自動被移除,不需要手動移除

Form Input Bindings

Basic Usage

可使用 v-model 在 form input, textarea, select 產生 two-way data binding,根據 input type 自動更新 element。v-model 是根據 user input event 更新 data + 特殊 edge case 的 syntax sugar

注意:v-model 會忽略 fome element 裡面初始的 value, checked, selected attributes,會使用 current active instance data 作為 source of truth,因此要在 js 的 data option 裡面宣告初始值。

v-model 會因為不同的 input element 使用不同的 properties,產生不同 events

  1. text, textarea 使用 value property 及 input event
  2. checkboxes, radiobuttons 使用 checked property 及 change event
  3. select 使用 value 為 prop 及 change event

注意:v-model 在 IME (Chinese, Japanese, Korean..) 語系中,在 IME composition 時,並不會讓 v-model 更新,如果需要處理輸入法的異動更新,要改用 input event listener 及 value binding

  • Text
<input v-model="message" placeholder="edit me" />
<p>Message is: {{ message }}</p>

<script>
Vue.createApp({
  data() {
    return {
      message: ''
    }
  }
}).mount('#v-model-basic')
</script>
  • Multiline Text
<span>Multiline message is:</span>
<p style="white-space: pre-line;">{{ message }}</p>
<br />
<textarea v-model="message" placeholder="add multiple lines"></textarea>

<script>
Vue.createApp({
  data() {
    return {
      message: ''
    }
  }
}).mount('#v-model-textarea')
</script>

textarea 不能用 interpolation

<!-- bad -->
<textarea>{{ text }}</textarea>

<!-- good -->
<textarea v-model="text"></textarea>
  • Checkbox
<input type="checkbox" id="checkbox" v-model="checked" />
<label for="checkbox">{{ checked }}</label>

<script>
Vue.createApp({
  data() {
    return {
      checked: false
    }
  }
}).mount('#v-model-checkbox')
</script>
<div id="v-model-multiple-checkboxes">
  <input type="checkbox" id="jack" value="Jack" v-model="checkedNames" />
  <label for="jack">Jack</label>
  <input type="checkbox" id="john" value="John" v-model="checkedNames" />
  <label for="john">John</label>
  <input type="checkbox" id="mike" value="Mike" v-model="checkedNames" />
  <label for="mike">Mike</label>
  <br />
  <span>Checked names: {{ checkedNames }}</span>
</div>

<script>
Vue.createApp({
  data() {
    return {
      checkedNames: []
    }
  }
}).mount('#v-model-multiple-checkboxes')
</script>
  • Radio
<div id="v-model-radiobutton">
  <input type="radio" id="one" value="One" v-model="picked" />
  <label for="one">One</label>
  <br />
  <input type="radio" id="two" value="Two" v-model="picked" />
  <label for="two">Two</label>
  <br />
  <span>Picked: {{ picked }}</span>
</div>

<script>
Vue.createApp({
  data() {
    return {
      picked: ''
    }
  }
}).mount('#v-model-radiobutton')
</script>
  • Select
<div id="v-model-select" class="demo">
  <select v-model="selected">
    <option disabled value="">Please select one</option>
    <option>A</option>
    <option>B</option>
    <option>C</option>
  </select>
  <span>Selected: {{ selected }}</span>
</div>

<script>
Vue.createApp({
  data() {
    return {
      selected: ''
    }
  }
}).mount('#v-model-select')
</script>

如果 v-model expression 的初始值跟 option 不吻合,<select> element 會以 "unselected" state 被 rendered。在 iOS 會造成 user 無法選擇第一個 element,因為 iOS 不會 fire a change event。

建議用 empty value 增加一個 disabled option,類似上面提供的例子一樣


multiple select

<select v-model="selected" multiple>
  <option>A</option>
  <option>B</option>
  <option>C</option>
</select>
<br />
<span>Selected: {{ selected }}</span>

<script>
Vue.createApp({
  data() {
    return {
      selected: ''
    }
  }
}).mount('#v-model-select')
</script>

v-for 實作dynamic options

<div id="v-model-select-dynamic" class="demo">
  <select v-model="selected">
    <option v-for="option in options" :value="option.value">
      {{ option.text }}
    </option>
  </select>
  <span>Selected: {{ selected }}</span>
</div>

<script>
Vue.createApp({
  data() {
    return {
      selected: 'A',
      options: [
        { text: 'One', value: 'A' },
        { text: 'Two', value: 'B' },
        { text: 'Three', value: 'C' }
      ]
    }
  }
}).mount('#v-model-select-dynamic')
</script>

Value Bindings

radio, checkbox, select option 中,v-model binding values 通常是 static string (checkbox 是 booleans)

<!-- `picked` is a string "a" when checked -->
<input type="radio" v-model="picked" value="a" />

<!-- `toggle` is either true or false -->
<input type="checkbox" v-model="toggle" />

<!-- `selected` is a string "abc" when the first option is selected -->
<select v-model="selected">
  <option value="abc">ABC</option>
</select>

如果要 dynamic property,可使用 v-bind

  • Checkbox

true-value, false-value 不會影響 input 的 value attribute

<input type="checkbox" v-model="toggle" true-value="yes" false-value="no" />
// when checked:
vm.toggle === 'yes'
// when unchecked:
vm.toggle === 'no'
  • Radio
<input type="radio" v-model="pick" v-bind:value="a" />
// when checked:
vm.pick === vm.a
  • Select Options
<select v-model="selected">
  <!-- inline object literal -->
  <option :value="{ number: 123 }">123</option>
</select>
// when selected:
typeof vm.selected // => 'object'
vm.selected.number // => 123

Modifiers

  • .lazy

v-model 預設會在每一次 input event 發生時,同步 input data,可增加 lazy modifier,修改為 change event 後同步資料

<!-- synced after "change" instead of "input" -->
<input v-model.lazy="msg" />
  • .number

如果想讓 user input 自動 typecast 為 number,可用 .number

<input v-model.number="age" type="number" />

如果 input value 無法被 parseFloat() parsing 時,會回傳原始 value

  • .trim

自動 trim space

<input v-model.trim="msg" />

v-model with Components

vue component 可產生 reusable inputs with customized behavior


Components Basics

以下為 vue component 的 example,通常在 vue application,會使用 Single File Component,而不是 string template。

Component 是 reusable instances with a name

<div id="components-demo">
  <button-counter></button-counter>
</div>

<script>
// Create a Vue application
const app = Vue.createApp({})

// Define a new global component called button-counter
app.component('button-counter', {
  data() {
    return {
      count: 0
    }
  },
  template: `
    <button v-on:click="count++">
      You clicked me {{ count }} times.
    </button>`
})

app.mount('#components-demo')
</script>

component 就是 reusable instance,故能夠使用data, computed, watch, methods, and lifecycle hooks

Reusing Components

每一個 component 都有各自的 instance,獨立的 count

<div id="components-demo">
  <button-counter></button-counter>
  <button-counter></button-counter>
  <button-counter></button-counter>
</div>

Organizing Components

在 app 裡面會使用 tree of nested components

例如會有 components for header, sidebar, content area

為了在 templates 裡面使用 component,必須要先向 Vue 註冊,有兩種註冊類型:global 與 local。

component method 是 global component

const app = Vue.createApp({})

// global component
app.component('my-component-name', {
  // ... options ...
})

Passing Data to Child Components with Props

props 是可以跟 component 註冊的 custom attribute

例如 blog post component,可用 props 提供 component 接受的 list of props

'title'變成該 component 的 property,然後就能在 template 裡面使用

<div id="blog-post-demo" class="demo">
  <blog-post title="My journey with Vue"></blog-post>
  <blog-post title="Blogging with Vue"></blog-post>
  <blog-post title="Why Vue is so fun"></blog-post>
</div>

<script>
const app = Vue.createApp({})

app.component('blog-post', {
  props: ['title'],
  template: `<h4>{{ title }}</h4>`
})

app.mount('#blog-post-demo')
</script>

<div id="blog-posts-demo">
  <blog-post
    v-for="post in posts"
    :key="post.id"
    :title="post.title"
  ></blog-post>
</div>

<script>
const App = {
  data() {
    return {
      posts: [
        { id: 1, title: 'My journey with Vue' },
        { id: 2, title: 'Blogging with Vue' },
        { id: 3, title: 'Why Vue is so fun' }
      ]
    }
  }
}

const app = Vue.createApp(App)

app.component('blog-post', {
  props: ['title'],
  template: `<h4>{{ title }}</h4>`
})

app.mount('#blog-posts-demo')
</script>

可使用 v-bind 做 dynamic pass props,在一開始不知道有多少 content 資料的時候很有用。

Listening to Child Components Events

ex: 要為 blogpost 增加 accessibility feature,把文字放大

增加 postFontSize data property

<div id="blog-posts-events-demo" class="demo">
  <div :style="{ fontSize: postFontSize + 'em' }">
    <blog-post
       v-for="post in posts"
       :key="post.id"
       :title="post.title"
       @enlarge-text="postFontSize += 0.1"
    ></blog-post>
  </div>
</div>

<script>
const app = Vue.createApp({
  data() {
    return {
      posts: [
        { id: 1, title: 'My journey with Vue'},
        { id: 2, title: 'Blogging with Vue'},
        { id: 3, title: 'Why Vue is so fun'}
      ],
      postFontSize: 1
    }
  }
})

app.component('blog-post', {
  props: ['title'],
  template: `
    <div class="blog-post">
      <h4>{{ title }}</h4>
      <button @click="$emit('enlargeText')">
        Enlarge text
      </button>
    </div>
  `
})

app.mount('#blog-posts-events-demo')
</script>

可用 emits option 檢查 all the events that a component emits

app.component('blog-post', {
  props: ['title'],
  emits: ['enlargeText']
})
  • Emitting a value with an Event

可在 $emit 增加第二個參數

<button @click="$emit('enlargeText', 0.1)">
  Enlarge text
</button>
<blog-post ... @enlarge-text="postFontSize += $event"></blog-post>

或是 event handler 為 method,可在第一個參數傳入 value

<blog-post ... @enlarge-text="onEnlargeText"></blog-post>
methods: {
  onEnlargeText(enlargeAmount) {
    this.postFontSize += enlargeAmount
  }
}
  • Using v-model on Components

custom events 可產生 custom inputs 跟 v-model 一起使用

<input v-model="searchText" />

跟上面一樣

<input :value="searchText" @input="searchText = $event.target.value" />

如果用在 component,就跟這個一樣

<custom-input
  :model-value="searchText"
  @update:model-value="searchText = $event"
></custom-input>

在 component 裡面的 <input> 必須滿足

  1. bind value property 到 modelValue prop
  2. input,新的 value 會產生 update:modelValue event
app.component('custom-input', {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  template: `
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
    >
  `
})

現在就可以使用 v-model

<custom-input v-model="searchText"></custom-input>

另一個在 component 實作 v-model 的方法,是用 computed properties 定義 getter, setter,get 要回傳 modelValue property,set 要產生相關 event

app.component('custom-input', {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  template: `
    <input v-model="value">
  `,
  computed: {
    value: {
      get() {
        return this.modelValue
      },
      set(value) {
        this.$emit('update:modelValue', value)
      }
    }
  }
})

Content Distribution with Slots

可傳送 content 給 component

<div id="slots-demo" class="demo">
  <alert-box>
    Something bad happened.
  </alert-box>
</div>

<script>
const app = Vue.createApp({})

app.component('alert-box', {
  template: `
    <div class="demo-alert-box">
      <strong>Error!</strong>
      <slot></slot>
    </div>
  `
})

app.mount('#slots-demo')
</script>

slot 裡面就放了 Something bad happened.

Dynamic Components

<div id="dynamic-component-demo" class="demo">
  <button
     v-for="tab in tabs"
     v-bind:key="tab"
     v-bind:class="['tab-button', { active: currentTab === tab }]"
     v-on:click="currentTab = tab"
   >
    {{ tab }}
  </button>

  <component v-bind:is="currentTabComponent" class="tab"></component>
</div>

<script>
const app = Vue.createApp({
  data() {
    return {
      currentTab: 'Home',
      tabs: ['Home', 'Posts', 'Archive']
    }
  },
  computed: {
    currentTabComponent() {
      return 'tab-' + this.currentTab.toLowerCase()
    }
  }
})

app.component('tab-home', {
  template: `<div class="demo-tab">Home component</div>`
})
app.component('tab-posts', {
  template: `<div class="demo-tab">Posts component</div>`
})
app.component('tab-archive', {
  template: `<div class="demo-tab">Archive component</div>`
})

app.mount('#dynamic-component-demo')
</script>

DOM Template Parsing Caveats

如果想直接在 DOM 撰寫 template,vue 會從 DOM 取得 template string,這樣可能會發生 browser 在 native HTML parsing 的警告

某些 html element 有限制裡面可以放的 element,ex: ul, ol, table, select

當這樣寫的時候,會產生警告

<table>
  <blog-post-row></blog-post-row>
</table>

解決方式,用 is ,裡面一定要用 "vue:" 為 prefix

<table>
  <tr is="vue:blog-post-row"></tr>
</table>

html attribute name 為 case-insensitive

如果在 in-DOM template 使用 camelCased prop name, event handler parameters,需要改為 kebab-cased (hyphen-delimited)

// camelCase in JavaScript

app.component('blog-post', {
  props: ['postTitle'],
  template: `
    <h3>{{ postTitle }}</h3>
  `
})
<!-- kebab-case in HTML -->

<blog-post post-title="hello!"></blog-post>

References

Vue Guide

重新認識 Vue.js

2022/03/14

Vue.js Essentials 2

Computed Properties and Watchers

Computed Properties

in-template expression 很方便好用,但不適合寫太多 code logic

<div id="computed-basics">
  <p>Has published books:</p>
  <span>{{ author.books.length > 0 ? 'Yes' : 'No' }}</span>
</div>

<script type="text/javascript">
Vue.createApp({
  data() {
    return {
      author: {
        name: 'John Doe',
        books: [
          'Vue 2 - Advanced Guide',
          'Vue 3 - Basic Guide',
          'Vue 4 - The Mystery'
        ]
      }
    }
  }
}).mount('#computed-basics')
</script>

為了簡化上面的 code,要使用 computed properties

<div id="computed-basics2">
  <p>Has published books:</p>
  <span>{{ publishedBooksMessage }}</span>
</div>

<script type="text/javascript">
Vue.createApp({
  data() {
    return {
      author: {
        name: 'John Doe',
        books: [
          'Vue 2 - Advanced Guide',
          'Vue 3 - Basic Guide',
          'Vue 4 - The Mystery'
        ]
      }
    }
  },
  computed: {
    // a computed getter
    publishedBooksMessage() {
      // `this` points to the vm instance
      return this.author.books.length > 0 ? 'Yes' : 'No'
    }
  }
}).mount('#computed-basics2')
</script>

因為 vm.publishedBooksMessage 相依於 vm.author.books,如果修改了 books,publishedBooksMessage 會自動更新

  • Computed Caching vs Methods

剛剛的 sample,也可以用 method 呼叫改寫

<p>{{ calculateBooksMessage() }}</p>
// in component
methods: {
  calculateBooksMessage() {
    return this.author.books.length > 0 ? 'Yes' : 'No'
  }
}

兩種寫法的結果一樣

差異是,computed properties 會根據 reactive dependencies 被 cached。

computed property 只會在 reactive dependencies 被修改時,重新 re-evaluate。

只要 author.books 沒有被修改,使用 publishedBooksMessage 都會直接 return,而不會運算該 function

因為 Date.now() 不是 reactive dependency,所以 now 這個 computed property 永遠不會更新

computed: {
  now() {
    return Date.now()
  }
}

如果是 method,就會在 re-render 時,重複呼叫該 function

如果不需要 caching,就用 method 寫法

  • Computed Setter

computed properties 預設為 getter-only,如果需要時,可增加 setter

// ...
computed: {
  fullName: {
    // getter
    get() {
      return this.firstName + ' ' + this.lastName
    },
    // setter
    set(newValue) {
      const names = newValue.split(' ')
      this.firstName = names[0]
      this.lastName = names[names.length - 1]
    }
  }
}
// ...

當呼叫 vm.fullName = 'John' 時,會呼叫 setter,並更新 vm.firstName, vm.lastName

Watchers

vue 提供 watch option 可以 react to data change

如果修改 data 會發生 asynchronous or expensive operations 時很有用

<div id="watch-example">
  <p>
    Ask a yes/no question:
    <input v-model="question" />
  </p>
  <p>{{ answer }}</p>
</div>

<!-- Since there is already a rich ecosystem of ajax libraries    -->
<!-- and collections of general-purpose utility methods, Vue core -->
<!-- is able to remain small by not reinventing them. This also   -->
<!-- gives you the freedom to use what you're familiar with.      -->
<script src="https://cdn.jsdelivr.net/npm/axios@0.12.0/dist/axios.min.js"></script>
<script>
  const watchExampleVM = Vue.createApp({
    data() {
      return {
        question: '',
        answer: 'Questions usually contain a question mark. ;-)'
      }
    },
    watch: {
      // whenever question changes, this function will run
      question(newQuestion, oldQuestion) {
        if (newQuestion.indexOf('?') > -1) {
          this.getAnswer()
        }
      }
    },
    methods: {
      getAnswer() {
        this.answer = 'Thinking...'
        axios
          .get('https://yesno.wtf/api')
          .then(response => {
            this.answer = response.data.answer
          })
          .catch(error => {
            this.answer = 'Error! Could not reach the API. ' + error
          })
      }
    }
  }).mount('#watch-example')
</script>
  • computed vs wated property

當有一些 data,需要根據其他 data 動態改變時,可能會 overuse watch,尤其是有 AngularJS 背景的開發者,要先考慮使用 computed property 而不是 watch callback

<div id="demo">{{ fullName }}</div>

<script>
const vm = Vue.createApp({
  data() {
    return {
      firstName: 'Foo',
      lastName: 'Bar',
      fullName: 'Foo Bar'
    }
  },
  watch: {
    firstName(val) {
      this.fullName = val + ' ' + this.lastName
    },
    lastName(val) {
      this.fullName = this.firstName + ' ' + val
    }
  }
}).mount('#demo')
</script>

watch 的版本會比較精簡

const vm = Vue.createApp({
  data() {
    return {
      firstName: 'Foo',
      lastName: 'Bar'
    }
  },
  computed: {
    fullName() {
      return this.firstName + ' ' + this.lastName
    }
  }
}).mount('#demo')

Class & Style Bindings

另一個常見需求是修改 element 的 class list 與 inline styles,因為都是 attributes,可以用 v-bind 修改。但會遇到很多 string 連接的問題。

vue 提供 classsytle,以 object/array 方式處理

Binding html classes

  • Object Syntax

可傳送物件到 :class 這是 v-bind:class 的縮寫

ex: 根據 data property: isActiveTruthy 決定 active class

<div :class="{ active: isActive }"></div>

ex: 可以有多個欄位,也可跟既有的 class 並存

<div
  class="static"
  :class="{ active: isActive, 'text-danger': hasError }"
></div>

以下的物件

data() {
  return {
    isActive: true,
    hasError: false
  }
}

會 render 為

<div class="static active"></div>

ex: 可以包裝為一個物件

<div :class="classObject"></div>
data() {
  return {
    classObject: {
      active: true,
      'text-danger': false
    }
  }
}

ex: 可以使用 computed property

<div :class="classObject"></div>
data() {
  return {
    isActive: true,
    error: null
  }
},
computed: {
  classObject() {
    return {
      active: this.isActive && !this.error,
      'text-danger': this.error && this.error.type === 'fatal'
    }
  }
}
  • Array Syntax

可用 array 傳入 :class

<div :class="[activeClass, errorClass]"></div>
data() {
  return {
    activeClass: 'active',
    errorClass: 'text-danger'
  }
}

會 render 為

<div class="active text-danger"></div>

ex: 可以用 ternary expression 處理 class toggle

<div :class="[isActive ? activeClass : '', errorClass]"></div>

ex: 如果有多個 conditional class,可以在 array 裡面用 object syntax

<div :class="[{ active: isActive }, errorClass]"></div>
  • with Components

在單一 root component 使用 class 時,既有的 classes 不會被 overwritten

<div id="app">
  <my-component class="baz boo"></my-component>
</div>

<script>
const app = Vue.createApp({})

app.component('my-component', {
  template: `<p class="foo bar">Hi!</p>`
})
</script>

會 render 為

<p class="foo bar baz boo">Hi</p>

如果是多個 root element,必須指定哪一個 component 接收這些 class,可使用 $attrs component property

<div id="app">
  <my-component class="baz"></my-component>
</div>

<script>
const app = Vue.createApp({})

app.component('my-component', {
  template: `
    <p :class="$attrs.class">Hi!</p>
    <span>This is a child component</span>
  `
})
</script>

Binding inline styles

  • Object Syntax

可使用 camelCase or kebab-case (use quotes with kebab-case) 的 css propery names

<div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>

<script>
data() {
  return {
    activeColor: 'red',
    fontSize: 30
  }
}
</script>

比較好的寫法是 bind sytle object

<div :style="styleObject"></div>

<script>
data() {
  return {
    styleObject: {
      color: 'red',
      fontSize: '13px'
    }
  }
}
</script>
  • Array Syntax
<div :style="[baseStyles, overridingStyles]"></div>
  • Auto-prefixing

如果在 :style 使用需要 Vendor Prefix 的 css property,vue 會自動加上適當的 prefix,Vue 會在 runtime 自動判斷是否有被目前的 browser 支援。

如果沒有支援,就會自動測試多個 prefix variants,嘗試找到支援的 css property

  • Multiple Values

可提供多個 prefixed values 給某個 style property

<div :style="{ display: ['-webkit-box', '-ms-flexbox', 'flex'] }"></div>

Conditional Rendering

v-if

在 directive expression 回傳 truth value 時,才會被 rendered

<h1 v-if="awesome">Vue is awesome!</h1>
<h1 v-else>Oh no 😢</h1>