Skip to content

第十章|练习参考答案 ​

Node 文件在本章环境运行;浏览器题在正文完整页面基础上修改。先记录预测,再比较普通输入、边界和失败结果。

实例与选项 ​

独立保存 discount.cjs。非法输入在赋值前检查,因此失败后折扣与总价均保持原状态。

js
const Vue = require('vue')
function createCart() {
  return new Vue({
    data() { return { price: 12, quantity: 2, discount: 0 } },
    computed: {
      total() { return Math.max(0, this.price * this.quantity - this.discount) }
    },
    methods: {
      setDiscount(value) {
        if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
          throw new Error('折扣必须是有限非负数字')
        }
        this.discount = value
      }
    }
  })
}
const first = createCart()
const second = createCart()
for (const value of [5, 100, -1, '5']) {
  try { first.setDiscount(value); console.log(first.total) }
  catch (error) { console.log(error.message, first.discount) }
}
// 19;0;折扣必须是有限非负数字 100;折扣必须是有限非负数字 100
console.log(second.discount, second.total) // 0 24
first.$destroy()
second.$destroy()

computed 从三个输入计算,不需要保存第二份总价状态。若商品数量改变,它自然失效;watch 版本则需要额外处理每个输入和初始化。data 每次返回新对象,让第二实例不受第一实例影响。

响应式与竞态 ​

动态标签实现保存为 labels.cjs:

js
const Vue = require('vue')
const vm = new Vue({
  data() { return { labels: {} } },
  computed: { keys() { return Object.keys(this.labels).sort().join(',') } },
  methods: {
    setLabel(id, text) { this.$set(this.labels, id, text) },
    removeLabel(id) { this.$delete(this.labels, id) }
  }
})
console.log(vm.keys) // ''
vm.setLabel('b', 'B'); console.log(vm.keys) // b
vm.setLabel('a', 'A'); console.log(vm.keys) // a,b
vm.removeLabel('b'); console.log(vm.keys) // a
vm.removeLabel('b'); console.log(vm.keys) // a
vm.$destroy()

搜索题保留正文 search.cjs 的实例实现,将 main 完整替换为下段。状态转换由正文实现负责,答案只改变任务完成方式:

js
async function main() {
  vm.query = 'A'
  await Vue.nextTick()
  vm.query = 'B'
  await Vue.nextTick()
  pending.get('B').reject(new Error('离线'))
  await Promise.resolve()
  pending.get('A').resolve('结果 A')
  await Promise.resolve()
  console.log(vm.result, vm.error, vm.status) // '' 离线 error
  vm.$destroy()
}

A 过期,不能覆盖当前错误。若在每个任务的 finally 无条件把 loading 改成 false,A 在 B 仍等待时结束,就会提前隐藏 B 的加载状态;finally 也需要同样的身份检查。本实现直接在当前任务的成功/错误分支推进 status,没有额外无条件清理状态的入口。

组件契约 ​

独立保存 switch.html。全局 vm 仅用于本题在控制台检查父状态,组件之间仍通过输入和事件通信。

html
<!doctype html>
<meta charset="utf-8">
<div id="app">
  <flag-switch v-model="checked" :disabled="disabled" @change="record">
    <template v-slot:status="state">{{ state.checked ? '已选' : '未选' }}</template>
  </flag-switch>
  <button id="reset" @click="checked = false">父组件复位</button>
  <button id="disable" @click="disabled = !disabled">禁用/启用</button>
  <p>{{ checked }} / {{ changes }}</p>
</div>
<script src="./node_modules/vue/dist/vue.js"></script>
<script>
Vue.component('flag-switch', {
  inheritAttrs: false,
  model: { prop: 'checked', event: 'change' },
  props: { checked: Boolean },
  template: `<label>
    <input type="checkbox" v-bind="$attrs" :checked="checked"
      @change="$emit('change', $event.target.checked)">
    <slot name="status" :checked="checked"></slot>
  </label>`
})
window.vm = new Vue({
  el: '#app',
  data() { return { checked: false, disabled: false, changes: 0 } },
  methods: {
    record(value) {
      if (typeof value !== 'boolean') throw new Error('负载必须是布尔值')
      this.changes++
    }
  }
})
</script>

点击 checkbox 后 true / 1;父组件复位后 false / 1,因为程序赋值不是子组件的新 change。禁用后用户点击不增加次数。若在控制台主动 dispatchEvent,脚本仍可派发事件;验证禁用行为应使用真实点击或元素 click(),不要把手工派发 change 当成用户输入。

生命周期与复用 ​

在正文 lifecycle.html 做四处明确修改:created 的 handler 改为下段;add/removeEventListener 的事件名都从 resize 改成 keydown;给组件在 created 中保存 window.lastCounter = this;父页面模拟按钮的方法改为派发 KeyboardEvent。

js
const handler = event => {
  if (event.key === 'Enter') this.count++
}

父组件 methods 中的 resize 方法改为:

js
resize() { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) }

打开修改后的页面,在控制台运行下面完整顺序。它通过保存的实例检查隐藏和销毁后的结果,不依赖不可见的 DOM。

js
async function verify() {
  const instance = window.lastCounter
  const send = key => window.dispatchEvent(new KeyboardEvent('keydown', { key }))
  send('Enter'); console.log(instance.count) // 1
  send('Escape'); console.log(instance.count) // 1
  document.querySelector('#toggle').click()
  await Vue.nextTick()
  send('Enter'); console.log(instance.count) // 1
  document.querySelector('#toggle').click()
  await Vue.nextTick()
  console.log(instance.count) // 1
  send('Enter'); console.log(instance.count) // 2
  document.querySelector('#discard').click()
  await Vue.nextTick()
  send('Enter'); console.log(instance.count) // 2
}
verify()

失败变体只删除 deactivated() 中的 this.stop,保留 beforeDestroy 清理,然后刷新并重新运行:隐藏后的 Enter 会将 count 改为 2,重显后的下一次 Enter 变为 3。start 的幂等性仍防止重复订阅,但不能替代停用退订。若同时删除 beforeDestroy 清理,最终销毁后的 Enter 也继续增加;这又是另一条失败路径。

Vuex 3 ​

保留正文 load-store.cjs 的 store,将 main 替换为下段,取消和当前失败都会被执行:

js
async function main() {
  await store.dispatch('list/load', Promise.resolve(['B']))
  let finish
  const run = store.dispatch('list/load', new Promise(resolve => { finish = resolve }))
  store.commit('list/cancel')
  finish(['A'])
  console.log(await run) // { applied: false }
  console.log(store.state.list.status, store.state.list.rows) // idle ['B']
  try { await store.dispatch('list/load', Promise.reject(new Error('失败'))) }
  catch (error) { console.log(error.message) } // 失败
  console.log(store.state.list.status) // error
}

下面 completion.cjs 是稳定的漏返回反例。手动 finish 保证服务在 dispatch 完成后才结束,避免使用立即兑现的 Promise 导致时序观察混淆。

js
const Vue = require('vue')
const Vuex = require('vuex')
Vue.use(Vuex)
let finish
let commitDone
const service = new Promise(resolve => { finish = resolve })
const store = new Vuex.Store({
  state: { count: 0 },
  mutations: { add(state, amount) { state.count += amount } },
  actions: {
    broken({ commit }) { commitDone = service.then(value => commit('add', value)) },
    async correct({ commit }, task) {
      const value = await task
      commit('add', value)
      return value
    }
  }
})
async function main() {
  await store.dispatch('broken')
  console.log(store.state.count) // 0,服务尚未结束,dispatch 却完成了
  finish(2)
  await commitDone
  console.log(store.state.count) // 2
  let release
  const pending = new Promise(resolve => { release = resolve })
  let done = false
  const run = store.dispatch('correct', pending).then(() => { done = true })
  await Promise.resolve()
  console.log(done, store.state.count) // false 2
  release(3)
  await run
  console.log(done, store.state.count) // true 5
}
main().catch(console.error)

await 只能等待 action 返回的完成信号。broken 没把 service 的 Promise 返回,调用方无法知道内部还在工作;correct 保持从任务、提交到返回值的完整链条。

Router 3 ​

独立保存 leave.cjs。先完成身份检查,再按离开条件拦截;每条路径只调用一次 next。

js
const Vue = require('vue')
const Router = require('vue-router')
Vue.use(Router)
const router = new Router({
  mode: 'abstract',
  routes: ['/home', '/private'].map(path => ({ path, component: {} }))
})
let dirty = false
router.beforeEach((to, from, next) => {
  if (from.path === '/private' && to.path !== '/private' && dirty) return next(false)
  next()
})
async function main() {
  await router.push('/private')
  dirty = true
  try { await router.push('/home') }
  catch (error) {
    console.log(Router.isNavigationFailure(error, Router.NavigationFailureType.aborted))
  } // true
  console.log(router.currentRoute.path) // /private
  dirty = false
  await router.push('/home')
  console.log(router.currentRoute.path) // /home
  try { await router.push('/home') }
  catch (error) {
    console.log(Router.isNavigationFailure(error, Router.NavigationFailureType.duplicated))
  } // true
}
main().catch(console.error)

参数复用题将 router.html 中出口改为 <router-view :key="$route.params.id"></router-view>。A→B 的新 key 使旧组件销毁、新组件创建,所以 created 重新运行,visits 每次从 1 开始;没有 key 时是同一个实例,visits 从 1 变2。先确定业务是否需要保留同一草稿,再选身份策略。

版本与工具链 ​

checkPair 完整修正为:

js
function checkPair({ vue, compiler }) {
  if (typeof vue !== 'string' || !vue) throw new Error('缺少运行时版本')
  if (!/^2\.6\.\d+$/.test(vue)) throw new Error('本检查只处理固定的 Vue 2.6 发布版本')
  if (!compiler) throw new Error('缺少模板编译器版本')
  if (compiler !== vue) throw new Error('模板编译器与运行时不一致')
  return '配对通过'
}
for (const pair of [
  {}, { vue: '2.6.14' },
  { vue: '2.6.14', compiler: '2.6.14' },
  { vue: '2.7.16', compiler: '2.7.16' }
]) {
  try { console.log(checkPair(pair)) }
  catch (error) { console.log(error.message) }
}

观察顺序为缺少运行时、缺少模板编译器、配对通过、范围外。第三个条件检查前先确定规则适用版本,避免错误要求所有 Vue 2.7 应用安装旧编译器。

在 versions.cjs 最后添加 holder.item = { extra: 'replaced' }; console.log(nextLabel.value),输出 replaced。item 本来就存在,替换经过它的 setter;新对象被观察并通知依赖。直接新增 extra 时没有同样入口。

迁移合同 ​

两个正文页面中的 data 都增加 step: 2,increment 改为 increment() { this.count += this.step };其他生命周期各自保留原版本正确名称。刷新后点击 +1 两次,日志为 2、4;点击销毁后再 +1,日志仍4且组件 DOM 不存在。

为了观察“当前步长”,可在两个页面的挂载后各加 window.counter = vm,先点击一次,再从控制台执行 window.counter.step = 3,点击第二次应从2变5。修改配置名没有改变方法依赖当前实例状态的契约。

失败变体只把新页面 beforeUnmount 改为 beforeDestroy,其他代码不变;步长固定2时销毁后再点击会得到6。页面不可见不能证明监听器已经结束。恢复 beforeUnmount 后重新加载验证,最终仍4。

这些结果覆盖实例状态、当前步长读取、事件次数、清理时机和目标 DOM。它们没有覆盖组件库、SSR、CSS 过渡、生产打包或所有路由;真正项目应为迁移切片增加相应合同,而不能把一个计数器成功当作整站升级成功。

返回第十章

用心记录每一步成长