LJKのBlog

学无止境

有默认值的参数传入undefined,则会取默认值。如果这个参数位于最后,则可以不传(相当于隐式传入undefined),否则得显式传入 undefined

1
2
3
4
5
6
7
8
9
function get() {
return 1
}

function foo(x = get(), y) {
console.log(x, y)
}

foo(undefined, 2) // 1 2

可变状态:

1
2
3
4
let objA = { name: "xiaoming" }
let objB = objA
objB.name = "lihua"
console.log(objA.name) // lihua

我们只修改了 objB 的 name,发现 ojbA 也发生了改变。这个就是可变状态。

可变状态间接修改了其它对象,会造成代码隐患。

解决方案:

  • 深度拷贝
  • 使用 immer、immutable-js 等处理不可变数据的库

不可变数据 immutable

当我们使用 deepClone 或 immer / immutable-js 创建一个新对象,新对象进行有副作用(side effect)的操作都不会影响到原来的数据。这就是 immutable。

deepClone 虽然实现了 immutable,但是开销太大,因为它完全创建了一个新的对象出来,其实,对于不会进行赋值操作的 value 保持引用也没关系。

所以在 2014 年,facebook 的 immutable-js 横空出世,即保证了 immutable ,在运行时判断数据间的引用情况,又兼顾了性能。

immutable.js

immutable-js 使用了另一套数据结构的 API ,与我们的常见操作有些许不同,它将所有的原生数据类型(Object, Array 等)都会转化成 immutable-js 的内部对象(Map,List 等),并且任何操作最终都会返回一个新的 immutable 的值。

immer

Immer 是 mobx 的作者写的一个 immutable 库,核心实现是利用 ES6 的 proxy,几乎以最小的成本实现了 js 的不可变数据结构,简单易用、体量小巧、设计巧妙,满足了我们对 JS 不可变数据结构的需求。

与 immutable-js 最大的不同,immer 是使用原生数据结构的 API 而不是像 immutable-js 那样转化为内置对象之后使用内置的 API,举个简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const produce = require("immer")

const state = {
done: false,
val: "string",
}

// 所有具有副作用的操作,都可以放入 produce 函数的第二个参数内进行
// 最终返回的结果并不影响原来的数据
const newState = produce(state, draft => {
draft.done = true
})

console.log(state.done) // false
console.log(newState.done) // true

通过上面的例子我们能发现,所有具有副作用的逻辑都可以放进 produce 的第二个参数的函数内部进行处理。在这个函数内部对原来的数据进行任何操作,都不会对原对象产生任何影响。

immer 原理

Immer 使用了 ES6 的新特性 Proxy 。

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

immer 中的 proxy

immer 的做法就是维护一份 state 在内部,劫持所有操作,内部来判断是否有变化从而最终决定如何返回。下面这个例子就是一个构造函数,如果将它的实例传入 Proxy 对象作为第一个参数,后面处理对象时,就可以使用其中的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Store {
constructor(state) {
this.modified = false
this.source = state
this.copy = null
}
get(key) {
if (!this.modified) return this.source[key]
return this.copy[key]
}
set(key, value) {
if (!this.modified) this.modifing()
return (this.copy[key] = value)
}
modifing() {
if (this.modified) return
this.modified = true
// 这里使用原生的 API 实现一层 immutable,
// 数组使用 slice 则会创建一个新数组。对象则使用解构
this.copy = Array.isArray(this.source) ? this.source.slice() : { ...this.source }
}
}

modified,source,copy 三个属性;get,set,modifing 三个方法。

modified 作为内置的 flag,判断如何进行设置和返回。

里面最关键的就应该是 modifing 这个函数,在第一次 set 的时候,实现一次 copy,copy 后的数据也是 immutable。

对于 Proxy 的第二个参数,简单做一层转发,任何对元素的读取和写入都转发到 store 实例内部方法去处理:

1
2
3
4
5
6
7
8
9
10
11
const PROXY_FLAG = "@@SYMBOL_PROXY_FLAG"
const handler = {
get(target, key) {
// 如果遇到了这个 flag 我们直接返回我们操作的 target
if (key === PROXY_FLAG) return target
return target.get(key)
},
set(target, key, value) {
return target.set(key, value)
},
}

这里在 getter 里面加一个 flag 的目的就在于将来从 proxy 对象中获取 store 实例更加方便。

最终我们能够完成这个 produce 函数:

1
2
3
4
5
6
7
8
9
10
11
12
function produce(state, producer) {
const store = new Store(state)
const proxy = new Proxy(store, handler)

// 执行我们传入的 producer 函数,我们实际操作的都是 proxy 实例,所有有副作用的操作都会在 proxy 内部进行判断,是否最终要对 store 进行改动。
producer(proxy)

// 处理完成之后,通过 flag 拿到 store 实例
const newState = proxy[PROXY_FLAG]
if (newState.modified) return newState.copy
return newState.source
}

这样,Store 构造函数、handler 处理对象,produce 处理 state,这三个模块最简版就完成了,将它们组合起来就是一个最 tiny 的 immer。真正的 immer 内部还有其他的功能。

当然,Proxy 作为一个新的 API,并不是所有环境都支持,Proxy 也无法 polyfill,所以 immer 在不支持 Proxy 的环境中,使用 Object.defineProperty 来进行一个兼容。

freeze

freeze 表示状态树在生成之后就被冻结不可继续操作。对于普通 JS 对象,我们可以使用 Object.freeze 来冻结我们生成的状态树对象,当然像 immer / immutable-js 内部自己有冻结的方法和逻辑。

  1. 使用configureStore创建 Redux store
    • configureStore 接受 reducer 函数作为命名参数
    • configureStore 使用的好用的默认设置自动设置 store
  2. 为 React 应用程序组件提供 Redux store
    • 使用 React-Redux <Provider> 组件包裹你的 <App />
    • 传递 Redux store 如 <Provider store={store}>
  3. 使用createSlice创建 Redux “slice” reducer
    • 使用字符串名称、初始状态和命名的 reducer 函数调用“createSlice”
    • Reducer 函数可以使用 Immer 来“改变”状态
    • 导出生成的 slice reducer 和 action creators
  4. 在 React 组件中使用 React-Redux useSelector/useDispatch钩子
    • 使用useSelector钩子从 store 中读取数据
    • 使用useDispatch钩子获取dispatch函数,并根据需要 dispatch actions

你将学到
  • RTK Query 如何简化 Redux 应用程序的数据获取
  • 如何设置 RTK Query
  • 如何使用 RTK Query 进行基本的数据获取和更新请求

概述

RTK Query 是一个强大的数据获取和缓存工具。它旨在简化在 Web 应用程序中加载数据的常见情况,无需自己手动编写数据获取和缓存逻辑

RTK Query 是一个包含在 Redux Toolkit 包中的可选插件,其功能构建在 Redux Toolkit 中的其他 API 之上。

动机

在过去的几年里,React 社区已经意识到 “数据获取和缓存” 实际上是一组不同于 “状态管理” 的关注点

RTK Query 从其他开创数据获取解决方案的工具中汲取灵感,例如 Apollo Client、React Query、Urql 和 SWR,但在其 API 设计中添加了独特的方法:

  • 数据获取和缓存逻辑构建在 Redux Toolkit 的 createSlicecreateAsyncThunk API 之上
  • 由于 Redux Toolkit 与 UI 无关,因此 RTK Query 的功能可以与任何 UI 层一起使用
  • API 请求接口是提前定义的,包括如何从参数生成查询参数和转换响应以进行缓存
  • RTK Query 还可以生成封装整个数据获取过程的 React hooks ,为组件提供 dataisFetching 字段,并在组件挂载和卸载时管理缓存数据的生命周期
  • RTK Query 提供“缓存数据项生命周期函数”选项,支持在获取初始数据后通过 websocket 消息流式传输缓存更新等用例
  • 我们有从 OpenAPI 和 GraphQL 模式生成 API slice 代码的早期工作示例
  • 最后,RTK Query 完全用 TypeScript 编写,旨在提供出色的 TS 使用体验

包含

RTK Query 包含在核心 Redux Toolkit 包的安装中。它可以通过以下两个入口点之一获得:

1
2
3
4
import { createApi } from "@reduxjs/toolkit/query"

/* 自动生成的特定于 React 的入口点 对应于定义请求接口的 hooks */
import { createApi } from "@reduxjs/toolkit/query/react"

RTK Query 主要由两个 API 组成:

  • createApi():RTK Query 功能的核心。它允许你定义一组请求接口来描述如何从一系列请求接口检索数据,包括如何获取和转换该数据的配置。在大多数情况下,你应该在每个应用程序中使用一次,根据经验,“每个基本 URL 一个 API slice”。
  • fetchBaseQuery(): fetch 的一个小包装 -US/docs/Web/API/Fetch_API),旨在简化请求。旨在为大多数用户在 createApi 中使用推荐的 baseQuery

RTK Query 缓存的设计思想

Redux 一直强调可预测性和显式行为。Redux 没有“魔法”,所有 Redux 逻辑都遵循相同的基本模式,即通过 reducers 调度操作和更新状态。这确实意味着有时你必须编写更多代码才能使事情发生。

Redux Toolkit 核心 API 不会更改 Redux 应用程序中的任何基本数据流 你仍在调度操作和编写 reducer,只是代码比手动编写所有逻辑要少。 RTK Query 同理。这是一个额外的抽象级别,但在内部,它仍在执行我们已经看到的用于管理异步请求及其响应的完全相同的步骤

但是,当你使用 RTK Query 时,会发生思维转变。我们不再考虑“管理状态”本身。相反,我们现在考虑“管理*缓存数据*”。与其尝试自己编写 reducer,我们现在将专注于定义 “这些数据来自哪里?”、“这个更新应该如何发送?”、“这个缓存的数据应该什么时候重新获取?”,以及“缓存的数据应该如何更新?”。如何获取、存储和检索这些数据成为我们不再需要担心的实现细节。

1
2
3
4
5
6
7
export const fetchNotifications = createAsyncThunk("notifications/fetchNotifications", async (_, { getState }) => {
const allNotifications = selectAllNotifications(getState())
const [latestNotification] = allNotifications
const latestTimestamp = latestNotification ? latestNotification.date : ""
const response = await client.get(`/fakeApi/notifications?since=${latestTimestamp}`)
return response.data
})

Thunk 参数

payload creator 的第二个参数是一个’ thunkAPI ‘对象,包含几个有用的函数和信息:

  • dispatchgetStatedispatchgetState 方法由 Redux store 提供。你可以在 thunk 中使用这些来发起 action,或者从最新的 Redux store 中获取 state (例如在发起 另一个 action 后获取更新后的值)。
  • extra:当创建 store 时,用于传递给 thunk 中间件的“额外参数”。这通常时某种 API 的包装器,比如一组知道如何对应用程序的服务器进行 API 调用并返回数据的函数,这样你的 thunk 就不必直接包含所有的 URL 和查询逻辑。
  • requestId:该 thunk 调用的唯一随机 ID ,用于跟踪单个请求的状态。
  • signal:一个AbortController.signal 函数,可用于取消正在进行的请求。
  • rejectWithValue:一个用于当 thunk 收到一个错误时帮助自定义 rejected action 内容的工具。

(如果你要手写 thunk 而不是使用 createAsyncThunk,则 thunk 函数将获取 (dispatch, getState) 作为单独的参数,而不是将他们放在一个对象中。)

你将学到
  • 如何使用 Redux “thunk” middleware 处理异步逻辑
  • 处理异步请求状态的开发模式
  • 如何使用 Redux Toolkit createAsyncThunk API 来简化异步调用

thunks 与异步逻辑

使用 Middleware 处理异步逻辑

就其本身而言,Redux store 对异步逻辑一无所知,任何异步都必须发生在 store 之外。

Redux middleware 扩展了 store,它允许:

  • dispatch action 时执行额外的逻辑(例如打印 action 的日志和状态)
  • 暂停、修改、延迟、替换或停止 dispatch 的 action
  • 编写可以访问 dispatchgetState 的额外代码
  • dispatch 如何接受除普通 action 对象之外的其他值,例如函数和 promise,通过拦截它们并 dispatch 实际 action 对象来代替

使用 middleware 的最常见原因是允许不同类型的异步逻辑与 store 交互。这允许你编写可以 dispatch action 和检查 store 状态的代码,同时使该逻辑与你的 UI 分开。

Redux 有多种异步 middleware,每一种都允许你使用不同的语法编写逻辑。最常见的异步 middleware 是 redux-thunk,它可以让你编写可能直接包含异步逻辑的普通函数。Redux Toolkit 的 configureStore 功能默认自动设置 thunk middleware我们推荐使用 thunk 作为 Redux 开发异步逻辑的标准方式

早些时候,我们看到了Redux 的同步数据流是什么样子。当引入异步逻辑时,我们添加了一个额外的步骤,middleware 可以运行像 AJAX 请求这样的逻辑,然后 dispatch action。这使得异步数据流看起来像这样:

thunk 函数

将 thunk middleware 添加到 Redux store 后,它允许你将 thunk 函数 直接传递给 store.dispatch。调用 thunk 函数时总是将 (dispatch, getState) 作为它的参数,你可以根据需要在 thunk 中使用它们。

Thunks 通常还可以使用 action creator 再次 dispatch 普通的 action,比如 dispatch(increment())

1
2
3
4
5
6
7
8
9
10
11
12
const store = configureStore({ reducer: counterReducer })

// thunk 函数:
const exampleThunkFunction = (dispatch, getState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(increment()) // dispatch 普通的 action
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}

store.dispatch(exampleThunkFunction) // 将 thunk 函数 直接传递给 store.dispatch

为了与 dispatch 普通 action 对象保持一致,我们通常将它们写为 _thunk action creators_,它返回 thunk 函数。这些 action creator 可以接受可以在 thunk 中使用的参数。

1
2
3
4
5
6
7
8
9
10
11
const logAndAdd = amount => {
return (dispatch, getState) => {
const stateBefore = getState()
console.log(`Counter before: ${stateBefore.counter}`)
dispatch(incrementByAmount(amount))
const stateAfter = getState()
console.log(`Counter after: ${stateAfter.counter}`)
}
}

store.dispatch(logAndAdd(5))

Thunk 通常写在 “slice” 文件中。createSlice 本身对定义 thunk 没有任何特殊支持,因此你应该将它们作为单独的函数编写在同一个 slice 文件中。这样,他们就可以访问该 slice 的普通 action creator,并且很容易找到 thunk 的位置。

“thunk” 这个词是一个编程术语,意思是 “一段做延迟工作的代码”.

编写异步 Thunks

Thunk 内部可能有异步逻辑,例如 setTimeoutPromiseasync/await。这使它们成为使用 AJAX 发起 API 请求的好地方。

Redux 的数据请求逻辑通常遵循以下可预测的模式:

  • 在请求之前 dispatch 请求“开始”的 action,以指示请求正在进行中。这可用于跟踪加载状态以允许跳过重复请求或在 UI 中显示加载中提示。
  • 发出异步请求
  • 根据请求结果,异步逻辑 dispatch 包含结果数据的“成功” action 或包含错误详细信息的 “失败” action。在这两种情况下,reducer 逻辑都会清除加载状态,并且要么展示成功案例的结果数据,要么保存错误值并在需要的地方展示。

这些步骤不是 _必需的_,而是常用的。(如果你只关心一个成功的结果,你可以在请求完成时发送一个“成功” action ,并跳过“开始”和“失败” action 。)

Redux Toolkit 提供了一个 createAsyncThunk API 来实现这些 action 的创建和 dispatch,我们很快就会看看如何使用它。

细节说明

如果我们手动编写一个典型的 async thunk 的代码,它可能看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const getRepoDetailsStarted = () => ({
type: "repoDetails/fetchStarted",
})
const getRepoDetailsSuccess = repoDetails => ({
type: "repoDetails/fetchSucceeded",
payload: repoDetails,
})
const getRepoDetailsFailed = error => ({
type: "repoDetails/fetchFailed",
error,
})
const fetchIssuesCount = (org, repo) => async dispatch => {
dispatch(getRepoDetailsStarted())
try {
const repoDetails = await getRepoDetails(org, repo)
dispatch(getRepoDetailsSuccess(repoDetails))
} catch (err) {
dispatch(getRepoDetailsFailed(err.toString()))
}
}

提示:

Redux Toolkit 有一个新的 RTK Query data fetching API。 RTK Query 是专门为 Redux 应用程序构建的数据获取和缓存解决方案,可以不用编写任何 thunk 或 reducer 来处理数据获取

总结

可以编写可复用的“selector 选择器”函数来封装从 Redux 状态中读取数据的逻辑

  • 选择器是一种函数,它接收 Redux state 作为参数,并返回一些数据

Redux 使用叫做“ middleware ”这样的插件模式来开发异步逻辑

  • 官方的处理异步 middleware 叫 redux-thunk,包含在 Redux Toolkit 中
  • Thunk 函数接收 dispatchgetState 作为参数,并且可以在异步逻辑中使用它们

你可以 dispatch 其他 action 来帮助跟踪 API 调用的加载状态

  • 典型的模式是在调用之前 dispatch 一个 “pending” 的 action,然后是包含数据的 “sucdess” 或包含错误的 “failure” action
  • 加载状态通常应该使用枚举类型,如 'idle' | 'loading' | 'succeeded' | 'failed'

Redux Toolkit 有一个 createAsyncThunk API 可以为你 dispatch 这些 action

  • createAsyncThunk 接受一个 “payload creator” 回调函数,它应该返回一个 Promise,并自动生成 pending/fulfilled/rejected action 类型
  • fetchPosts 这样生成的 action creator 根据你返回的 Promise dispatch 这些 action
  • 可以使用 extraReducers 字段在 createSlice 中监听这些 action,并根据这些 action 更新 reducer 中的状态。
  • action creator 可用于自动填充 extraReducers 对象的键,以便切片知道要监听的 action。
  • Thunk 可以返回 promise。 具体对于createAsyncThunk,你可以await dispatch(someThunk()).unwrap()来处理组件级别的请求成功或失败。

注意

如果 action 需要包含唯一 ID 或其他一些随机值,请始终先生成该随机值并将其放入 action 对象中。

Reducer 中永远不应该计算随机值,因为这会使结果不可预测。

解释:深入理解 redux 之 reducer 为什么是纯函数

不得修改传入的参数

以下是修改传入参数的示例:

1
2
3
4
5
6
7
const params = { a: 1 }
function log(params) {
console.log(params)
}
log(params) // {a: 1}
params.a = 2
log(params) // {a: 2}

不得调用非纯函数

redux 的核心提供可预测化的状态管理,即无论何时特定的 action 触发的行为永远保持一致,试想如果 reducer 中有 Date.now()等非纯函数,即使同样的 action,那么 reducer 处理过程中也是有所不同的,不再能保证可预测性。

执行有副作用的操作

同样,副作用的操作也会带来不可预测性。

api 请求该如何执行

显然 api 操作是不可避免的,因为总要向后台请求数据,那么 api 请求应该如何做呢?这里有两个办法:

  • 在 dispatch 方法之前进行 api 请求:在 dispatch 之外先进行 api 异步请求,当收到请求结果后,根据结果的不同选择 dispatch 不同的 action;
  • 应用 redux-thunk、redux-promise 等中间件,就可以在 dispatch 函数中直接执行 api 请求等异步操作了。

总结

Redux action creators 可以使用一个正确的内容模板去构造(prepare)action 对象

  • createSlicecreateAction 可以接受一个返回 action payload 的 “prepare callback”
  • 诸如唯一的 ID 和一些随机值应该放在 action 里,而不是在 reducer 中去计算

Reducers 内(仅)应该包含 state 的更新逻辑

  • Reducers 内可以包含计算新 state 所需的任意逻辑
  • Action 对象内应该包含足够描述即将发生什么事的信息

创建 Redux Store

Redux Slice

“slice” 是应用中单个功能的 Redux reducer 逻辑和 action 的集合, 通常一起定义在一个文件中。

比如,在一个博客应用中,store 的配置大致长这样:

1
2
3
4
5
6
7
8
9
10
11
12
import { configureStore } from "@reduxjs/toolkit"
import usersReducer from "../features/users/usersSlice"
import postsReducer from "../features/posts/postsSlice"
import commentsReducer from "../features/comments/commentsSlice"

export default configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
comments: commentsReducer,
},
})

创建 Slice Reducer 和 Action

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { createSlice } from "@reduxjs/toolkit"

export const counterSlice = createSlice({
name: "counter",
initialState: {
value: 0,
},
reducers: {
increment: state => {
// Redux Toolkit 允许我们在 reducers 写 "可变" 逻辑。
// 并不是真正的改变 state 因为它使用了 immer 库
// 当 immer 检测到 "draft state" 改变时,会基于这些改变去创建一个新的不可变的 state
state.value += 1
},
decrement: state => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
},
},
})

export const { increment, decrement, incrementByAmount } = counterSlice.actions

export default counterSlice.reducer

Redux Toolkit 有一个名为 createSlice 的函数,它负责生成 action 类型字符串、action creator 函数和 action 对象。

createSlice 内部使用了一个名为 Immer 的库。 Immer 使用一种 “Proxy” 包装你提供的数据,当你尝试 ”mutate“ 这些数据的时候,Immer 会跟踪你尝试进行的所有更改,然后使用该更改列表返回一个安全的、不可变的更新值,就好像你手动编写了所有不可变的更新逻辑一样。

Reducer 的规则

  • 仅使用 stateaction 参数计算新的状态值
  • 禁止直接修改 state。必须通过复制现有的 state 并对复制的值进行更改的方式来做 _不可变更新(immutable updates)_。
  • 禁止任何异步逻辑、依赖随机值或导致其他“副作用”的代码

“不可变更新(Immutable Updates)” 这个规则尤其重要,值得进一步讨论。

Reducer 与 Immutable 更新

在 Redux 中,**永远 不允许在 reducer 中直接更改 state 的原始对象!**

1
2
// ❌ 非法 - 默认情况下,这将更改 state!
state.value = 123

这就是为什么 Redux Toolkit 的 createSlice 函数可以让你以更简单的方式编写不可变更新!

警告

只能 在 Redux Toolkit 的 createSlicecreateReducer 中编写 “mutation” 逻辑,因为它们在内部使用 Immer!如果你在没有 Immer 的 reducer 中编写 mutation 逻辑,它 改变状态并导致错误!

用 Thunk 编写异步逻辑

到目前为止,我们应用程序中的所有逻辑都是同步的:

  1. dispatch action
  2. store 调用 reducer 来计算新状态
  3. dispatch 函数完成并结束

但是,我们的应用程序通常具有异步逻辑,我们需要一个地方在我们的 Redux 应用程序中放置异步逻辑。

thunk 是一种特定类型的 Redux 函数,可以包含异步逻辑。Thunk 是使用两个函数编写的:

  • 一个内部 thunk 函数,它以 dispatchgetState 作为参数
  • 外部创建者函数,它创建并返回 thunk 函数

示例:

1
2
3
4
5
6
7
8
9
// 外部的 thunk creator 函数, 它使我们可以执行异步逻辑
export const incrementAsync = amount => {
// 内部的 thunk 函数
return (dispatch, getState) => {
setTimeout(() => {
dispatch(incrementByAmount(amount)) // 调用dispatch修改store
}, 1000)
}
}

显然,incrementAsync() 返回的不是 action(action 是具有type字段的纯函数),而是一个函数,但它的使用方式和普通的 action 是一样的:

1
2
3
4
5
6
7
8
export function Counter() {
const dispatch = useAppDispatch();

return <>
<button onClick=()=>dispatch(increment()) >+</button>
<button onClick=()=>dispatch(incrementAsync()) >+</button>
</>
}

这是依赖 “middleware” 机制实现的,Redux 的 store 可以使用 “middleware” 进行扩展,中间件是一种可以添加额外功能的附加组件或插件。其最常见的用途就是实现异步逻辑,同时仍能与 store 对话。

Redux Thunk 中间件,代码很短:

1
2
3
4
5
6
7
8
9
10
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === "function") {
return action(dispatch, getState) // 这里面允许调用dispatch修改store
}

return next(action)
}

它先判断传入 dispatch 的 action 是函数还是对象。如果是一个函数,则调用函数,并返回结果。否则,传入的是普通 action 对象,就把这个 action 传递给 store 处理。

React Counter 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import React, { useState } from "react"
import { useSelector, useDispatch } from "react-redux"
import { decrement, increment, incrementByAmount, incrementAsync, selectCount } from "./counterSlice"
import styles from "./Counter.module.css"

export function Counter() {
const count = useSelector(selectCount)
const dispatch = useDispatch()
const [incrementAmount, setIncrementAmount] = useState("2")

return (
<div>
<div className={styles.row}>
<button className={styles.button} aria-label="Increment value" onClick={() => dispatch(increment())}>
+
</button>
<span className={styles.value}>{count}</span>
<button className={styles.button} aria-label="Decrement value" onClick={() => dispatch(decrement())}>
-
</button>
</div>
{/* 这里省略了额外的 render 代码 */}
</div>
)
}

使用 useSelector 提取数据

useSelector 这个 hook 让我们的组件从 Redux 的 store 状态树中提取它需要的任何数据。

我们默认 组件中不能引入 store。所以useSelector负责在幕后与 Redux store 对话。

示例:

1
const countPlusTwo = useSelector(state => state.counter.value + 2)

useSelector 会调用 store.getState() 获取 state,然后返回 state.counter.value + 2 的值。

每当一个 action 被 dispatch 并且 Redux store 被更新时,useSelector 将重新运行我们的选择器函数。如果选择器返回的值与上次不同,useSelector 将确保我们的组件使用新值重新渲染。

使用 useDispatch 来 dispatch action

类似地,我们知道如果我们可以访问 Redux store,可以 store.dispatch(increment())

由于我们无法访问 store 本身,因此我们需要某种方式来访问 dispatch 方法。

useDispatch hook 为我们完成了这项工作,并从 Redux store 中为我们提供了实际的 dispatch 方法:

1
const dispatch = useDispatch()

组件 State 与表单

在 React + Redux 应用中,你的全局状态应该放在 Redux store 中,你的本地状态应该保留在 React 组件中。

大多数表单的 state 不应该保存在 Redux 中。 相反,在编辑表单的时候把数据存到表单组件中,当用户提交表单的时候再 dispatch action 来更新 store。

Providing the Store

我们已经看到我们的组件可以使用 useSelectoruseDispatch 这两个 hook 与 Redux 的 store 通信。奇怪的是,我们并没有导入 store,那么这些 hooks 怎么知道要与哪个 Redux store 对话呢?

答案是使用 Context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from "react"
import { createRoot } from "react-dom/client"
import { Provider } from "react-redux"
import { store } from "app/store"
import App from "./App"

const container = document.getElementById("root")!
const root = createRoot(container)

root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)

总结

  • 我们可以使用 Redux Toolkit configureStore API 创建一个 Redux store

    • configureStore 接收 reducer 函数来作为命名参数
    • configureStore 自动使用默认值来配置 store
  • 在 slice 文件中编写 Redux 逻辑

    • 一个 slice 包含一个特定功能或部分的 state 相关的 reducer 逻辑和 action
    • Redux Toolkit 的 createSlice API 为你提供的每个 reducer 函数生成 action creator 和 action 类型
  • Redux reducer 必须遵循以下原则

    • 必须依赖 stateaction 参数去计算出一个新 state
    • 如果要修改 state,只能先拷贝 state 副本,然后去修改副本
    • 不能包含任何异步逻辑或其他副作用
    • Redux Toolkit 的 createSlice API 内部使用了 Immer 库才达到表面上直接修改(”mutating”)state 也实现不可变更新(_immutable updates_)的效果
  • 一般使用 “thunks” 来开发特定的异步逻辑

    • Thunks 接收 dispatchgetState 作为参数
    • Redux Toolkit 内置并默认启用了 redux-thunk 中间件
  • 使用 React-Redux 来做 React 组件和 Redux store 的通信

    • 在应用程序根组件包裹 <Provider store={store}> 使得所有组件都能访问到 store
    • 全局状态应该维护在 Redux store 内,局部状态应该维护在局部 React 组件内

  1. 官网下载 SecureCRT 9.3.2:https://www.vandyke.com/cgi-bin/account_login.php?pid=scrt_ubuntu2264_deb_932

  2. 安装:

    1
    sudo dpkg -i scrt-9.3.2-2978.ubuntu22-64.x86_64.deb
  3. 创建securecrt_linux_crack.pl 文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    #!/usr/bin/perl

    use strict;
    use warnings;
    use File::Copy qw(move);

    sub license {
    print "\n".
    "License:\n\n".
    "\tName:\t\tygeR\n".
    "\tCompany:\tTEAM ZWT\n".
    "\tSerial Number:\t03-69-167482\n".
    "\tLicense Key:\tACM9SP KG5KUX E276JS N3NEMG ACM2M3 Q9FSC4 116U6V GDEPA2\n".
    "\tIssue Date:\t04-24-2023\n\n\n";
    }

    sub usage {
    print "\n".
    "help:\n\n".
    "\tperl securecrt_forgeek_crack.pl <file>\n\n\n".
    "\tperl securecrt_forgeek_crack.pl /usr/bin/SecureCRT\n\n\n".
    "\n";

    &license;

    exit;
    }
    &usage() if ! defined $ARGV[0] ;

    my $file = $ARGV[0];

    open FP, $file or die “can not open file $!”;
    binmode FP;

    open TMPFP, ‘>’, ‘/tmp/.securecrt.tmp’ or die “can not open file $!”;

    my $buffer;
    my $unpack_data;
    my $crack = 0;

    while(read(FP, $buffer, 1024)) {
    $unpack_data = unpack(‘H*’, $buffer);
    if ($unpack_data =~ m/785782391ad0b9169f17415dd35f002790175204e3aa65ea10cff20818/) {
    $crack = 1;
    last;
    }
    if ($unpack_data =~ s/6e533e406a45f0b6372f3ea10717000c7120127cd915cef8ed1a3f2c5b/785782391ad0b9169f17415dd35f002790175204e3aa65ea10cff20818/ ){
    $buffer = pack(‘H*’, $unpack_data);
    $crack = 2;
    }
    syswrite(TMPFP, $buffer, length($buffer));
    }

    close(FP);
    close(TMPFP);

    if ($crack == 1) {
    unlink ‘/tmp/.securecrt.tmp’ or die “can not delete files $!”;
    print “It has been cracked\n”;
    &license;
    exit 1;
    } elsif ($crack == 2) {
    move ‘/tmp/.securecrt.tmp’, $file or die ‘Insufficient privileges, please switch the root account.’;
    chmod 0755, $file or die ‘Insufficient privileges, please switch the root account.’;
    print “crack successful\n”;
    &license;
    } else {
    die ‘error’;
    }

    1
    2
    3
    4
    5
    6
    7
    8

    4. 破解:

    ```sh
    sudo chmod +x securecrt_linux_crack.pl
    cp /usr/bin/SecureCRT /tmp/ # 拷贝SecureCRT到/tmp
    ./securecrt_linux_crack.pl /tmp/SecureCRT # 开始破解,如果破解成功,会提示 crack successful 并输出license信息
    cp /tmp/SecureCRT /usr/bin/ # 将破解后的SecureCRT覆盖原SecureCRT
  4. 启动 SecureCRT 并输入 license,如果不成功,则随便百度一个 SecureCRT9.3.2 的 license 填上就行,如果百度搜不到,就用 windows 系统生成一个,因为 license 是通用的。

安装

注意:不要直接用系统自带的应用商店安装。

http://www.sublimetext.com/docs/linux_repositories.html#apt

1
2
3
4
5
6
7
8
9
gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/sublimehq-archive.gpg > /dev/null

echo "deb https://download.sublimetext.com/ apt/stable/" | sudo tee /etc/apt/sources.list.d/sublime-text.list

sudo apt update
sudo apt install sublime-text

# 如果安装失败
sudo apt install apt-transport-https

破解

  1. 打开页面:https://hexed.it/
  2. 打开文件:/opt/sublime_text/sublime_text
  3. 搜索 80 78 05 00 0F 94 C1 并替换为 C6 40 05 01 48 85 C9
  4. 导出 并 覆盖 /opt/sublime_text/sublime_text
  5. 不出意外的话,破解成功

https://notabug.org/doublesine/navicat-keygen/src/linux/doc/how-to-use.zh-CN.md

编译安装 navicat-keygen

https://notabug.org/doublesine/navicat-keygen/src/linux/doc/how-to-build.zh-CN.md

1
2
3
4
5
6
7
8
9
10
sudo apt-get install cmake
sudo apt-get install libfmt-dev libssl-dev rapidjson-dev

git clone -b linux --single-branch https://notabug.org/doublesine/navicat-keygen.git
cd navicat-keygen

mkdir build
cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
cmake --build . -- -j12

官网下载 navicat

https://www.navicat.com/en/download/navicat-premium

1
wget https://www.navicat.com/download/direct-download?product=navicat16-premium-en.AppImage&location=1

开始注册

https://notabug.org/doublesine/navicat-keygen/src/linux/doc/how-to-use.zh-CN.md

1
2
3
4
5
6
# 当前位于~/opt目录下
mkdir navicat16-premium-cs
sudo mount -o loop ~/navicat16-premium-cs.AppImage ~/navicat16-premium-cs
cp -r navicat16-premium-cs navicat16-premium-cs-patched
sudo umount navicat16-premium-cs
rm -rf navicat16-premium-cs

进入刚才编译安装好的 navicat-keygen/build 目录,使用 navicat-patcher 替换官方公钥:

1
./navicat-patcher ~/opt/navicat16-premium-cs-patched

将文件重新打包成 AppImage:

1
2
3
wget 'https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage'
chmod +x appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage ~/opt/navicat16-premium-cs-patched ~/opt/navicat16-premium-cs-patched.AppImage

运行刚生成的 AppImage:

1
2
chmod +x ~/opt/navicat16-premium-en-patched.AppImage
~/opt/navicat16-premium-en-patched.AppImage

还是在 navicat-keygen/build 目录,navicat-keygen 来生成 序列号激活码

1
./navicat-keygen --text ./RegPrivateKey.pem

你会被要求选择 Navicat 产品类别、Navicat 语言版本和填写主版本号。之后一个随机生成的 序列号 将会给出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
***************************************************
* navicat-keygen by @DoubleLabyrinth *
* version: 16.0.7.0 *
***************************************************

[*] Select Navicat product:
0. DataModeler
1. Premium
2. MySQL
3. PostgreSQL
4. Oracle
5. SQLServer
6. SQLite
7. MariaDB
8. MongoDB
9. ReportViewer

(Input index)> 1

[*] Select product language:
0. English
1. Simplified Chinese
2. Traditional Chinese
3. Japanese
4. Polish
5. Spanish
6. French
7. German
8. Korean
9. Russian
10. Portuguese

(Input index)> 1

[*] Input major version number:
(range: 11 ~ 16, default: 16)> 16

[*] Serial number:
NAVB-EZF4-7T7X-9MPG

[*] Your name:

你可以使用这个 序列号 来暂时激活 Navicat。

之后你会被要求填写 用户名组织名。你可以随意填写,但别太长。

1
2
3
4
[*] Your name: Double Sine
[*] Your organization: PremiumSoft CyberTech Ltd.

[*] Input request code in Base64: (Double press ENTER to end)

之后你会被要求填写请求码。注意不要关闭 keygen。

  1. 断开网络. 找到注册窗口,填写 keygen 给你的 序列号,然后点击 激活
  2. 通常在线激活会失败,所以在弹出的提示中选择 手动激活
  3. 复制 请求码 到 keygen,连按两次回车结束。
1
2
3
4
5
6
7
8
9
10
11
[*] Input request code in Base64: (Double press ENTER to end)
ds7CnjEnNL+8Rme9Q5iD+3t9Tfuq9W6FzVN/3UZwC5zzecmM9EwyHJuZSovKJNSBTzL6AiGyxliTuKPWmLqAdwiKGLuD+mSaZ0syk0jTakVbXmbAk9maFkTz8SK5jMwnQVM/WBZcI0z2Jg1GnOCZVClu/Lo3/WF+XncS+alc2gshG9dUaI44Cqfvp/u1/EYso5fX/bjeBXaFW1/zj+uuRjVv5l0gt7JsTh9byGVxSDTO4zI64Iz9+58QYCbI9zKM+3G9Gou0UlNKjDYw4gN5+4dpiWAjitVTcL3oQzvflgAXjGlT/P6MA+8Xb5PEPJrEdxsErJObxBhO4cTH52wKoQ==

[*] Request Info:
{"K":"NAVBEZF47T7X9MPG", "DI":"AFCFB038A240942D8776", "P":"linux"}

[*] Response Info:
{"K":"NAVBEZF47T7X9MPG","DI":"AFCFB038A240942D8776","N":"Double Sine","O":"PremiumSoft CyberTech Ltd.","T":1644837835}

[*] Activation Code:
OY8Ib0brsepeS99it4s4WTDPQuKgu93WembLJ0bzr6M30Wh24reH1/ocaZ2Ek1bRBi5lqu2xBv/MpAcFUlstJANtavArkFnXYv0ZZiF3VF70De5GMe/VjkreNhjCGtTZcQKr8fabBTPjJuN0P+Hi1xWwMs9zJMuH+MJTmCQpbM4gu86YrFK/EDcdHtA4ZFgUI0SgYW8lwFausLFHp7C4uIQNbjtv4KP3XolDUrAx4lqg6bklgZ9C8ZjUpg28VVR9Ym37b1Fup7Y7C8OjmmMiAp8N5z8m6cA/EjcSLfLOMGf8jsAK0GHz5/AGUqAXWifv9h9cxPA35UgytqI9F2IH/Q==

最终你会得到一个 base64 编码的 激活码

将之复制到 手动激活 的窗口,然后点击 激活

如果没有什么意外,应该可以成功激活。

最后清理:

1
2
3
$ rm ~/opt/navicat16-premium-cs.AppImage
$ rm -rf ~/opt/navicat16-premium-cs-patched
$ mv ~/opt/navicat16-premium-cs-patched.AppImage ~/opt/navicat16-premium-cs.AppImage

MDN:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

1
const p = new Proxy(target, handler)
  • target:要使用Proxy包装的目标对象(可以是任何类型的对象,包括原生数组、函数、甚至另一个代理)。
  • handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理p的行为。

方法

1
Proxy.revocable()

创建一个可撤销的Proxy对象。

handler对象的方法

handler对象是一个容纳一批特定属性的占位符对象。它包含有Proxy的各个捕获器(trap)。

所有的捕获器是可选的。如果没有定义某个捕获器,那么就会保留源对象的默认行为。

getPrototypeOf()

handler.getPrototypeOf() 是一个代理(Proxy)方法,当读取代理对象的原型时,该方法就会被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const monster1 = {
eyeCount: 4
};

const monsterPrototype = {
eyeCount: 2
};

const handler = {
getPrototypeOf(target) {
return monsterPrototype;
}
};

const proxy1 = new Proxy(monster1, handler);

console.log(Object.getPrototypeOf(proxy1) === monsterPrototype);
// Expected output: true

console.log(Object.getPrototypeOf(proxy1).eyeCount);
// Expected output: 2

setPrototypeOf()

handler.setPrototypeOf() 方法主要用来拦截 Object.setPrototypeOf().

1
2
3
4
const p = new Proxy(target, {
setPrototypeOf: function(target, prototype) {
}
});

isExtensible()

handler.isExtensible() 方法用于拦截对对象的 Object.isExtensible()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const monster1 = {
canEvolve: true
};

const handler1 = {
isExtensible(target) {
return Reflect.isExtensible(target);
},
preventExtensions(target) {
target.canEvolve = false;
return Reflect.preventExtensions(target);
}
};

const proxy1 = new Proxy(monster1, handler1);

console.log(Object.isExtensible(proxy1)); // true

console.log(monster1.canEvolve); // true

Object.preventExtensions(proxy1); // true

console.log(Object.isExtensible(proxy1)); // true

console.log(monster1.canEvolve); // true

preventExtensions()

handler.preventExtensions() 方法用于设置对Object.preventExtensions()的拦截。

Object.preventExtensions()方法让一个对象变的不可扩展,也就是永远不能再添加新的属性。

1
2
3
4
var p = new Proxy(target, {
preventExtensions: function(target) {
}
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const monster1 = {
canEvolve: true
};

const handler1 = {
preventExtensions(target) {
target.canEvolve = false;
Object.preventExtensions(target);
return true;
}
};

const proxy1 = new Proxy(monster1, handler1);

console.log(monster1.canEvolve); // true

Object.preventExtensions(proxy1);

console.log(monster1.canEvolve); // fasle

getOwnPropertyDescriptor()

handler.getOwnPropertyDescriptor() 方法是 Object.getOwnPropertyDescriptor() 的钩子。

1
2
3
4
var p = new Proxy(target, {
getOwnPropertyDescriptor: function(target, prop) {
}
});
1
2
3
4
5
6
7
8
9
10
const p = new Proxy({ a: 20 }, {
getOwnPropertyDescriptor(target, prop) {
console.log('called: ' + prop)
return { configurable: true, enumerable: true, value: 10 }
}
})

console.log(Object.getOwnPropertyDescriptor(p, 'a').value)
// called: a
// 10

defineProperty()

handler.defineProperty() 用于拦截对象的 Object.defineProperty() 操作。

vue2的双向绑定就是通过 Object.defineProperty() 实现的。

1
2
3
4
var p = new Proxy(target, {
defineProperty: function(target, property, descriptor) {
}
});

has()

handler.has() 方法是针对 in 操作符的代理方法。

示例,_开头的属性为私有属性,使用in判断的时候返回false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const handler1 = {
has(target, key) {
if (key[0] === '_') {
return false;
}
return key in target;
}
};

const monster1 = {
_secret: 'easily scared',
eyeCount: 4
};

const proxy1 = new Proxy(monster1, handler1);

console.log('eyeCount' in proxy1); // true

console.log('_secret' in proxy1); // false

console.log('_secret' in monster1); // true

get()

handler.get() 方法用于拦截对象的读取属性操作。

1
2
3
4
var p = new Proxy(target, {
get: function(target, property, receiver) {
}
});
1
2
3
4
5
6
7
const p = new Proxy({ a: 10 }, {
get(target, prop, receiver) {
return target[prop] * 2
}
})

console.log(p.a) // 20

set()

handler.set() 方法是设置属性值操作的捕获器。

1
2
3
4
const p = new Proxy(target, {
set: function(target, property, value, receiver) {
}
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const monster1 = { eyeCount: 4 };

const handler1 = {
set(obj, prop, value) {
if ((prop === 'eyeCount') && ((value % 2) !== 0)) {
console.log('必须是偶数');
} else {
return Reflect.set(...arguments);
}
}
};

const proxy1 = new Proxy(monster1, handler1);

proxy1.eyeCount = 1; // 必须是偶数

console.log(proxy1.eyeCount); // 4

proxy1.eyeCount = 2;
console.log(proxy1.eyeCount); // 2

deleteProperty()

handler.deleteProperty() 方法用于拦截对对象属性的 delete 操作。

1
2
3
4
var p = new Proxy(target, {
deleteProperty: function(target, property) {
}
});

ownKeys()

handler.ownKeys() 方法用于拦截 Reflect.ownKeys().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const monster1 = {
_age: 111,
[Symbol('secret')]: 'I am scared!',
eyeCount: 4
}

const proxy1 = new Proxy(monster1, {
ownKeys(target) {
return Reflect.ownKeys(target)
}
})

for (const key of Object.keys(proxy1)) {
console.log(key)
// Expected output: "_age"
// Expected output: "eyeCount"
}

apply()

handler.apply() 方法用于拦截函数的调用。

1
2
3
4
5
6
7
8
9
10
const sum = (a, b) => a + b
const handler = {
apply: (target, _, args) => {
return target(...args) * 10
},

}
const proxy = new Proxy(sum, handler)
console.log(sum(1, 2)) // 3
console.log(proxy(1, 2)) // 30

construct()

handler.construct() 方法用于拦截 new 操作符。为了使 new 操作符在生成的 Proxy 对象上生效,用于初始化代理的目标对象自身必须具有 [[Construct]] 内部方法(即 new target 必须是有效的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
function sum(a, b) {
return { a, b }
}
const handler = {
construct: (target, args) => {
for (let index = 0; index < args.length; index++) {
args[index] += 1
}
return new target(...args)
}
}
const proxy = new Proxy(sum, handler)
console.log(new proxy(1, 2)) // {a: 2, b: 3}

上例中,sum不能是只能是普通函数,不能是箭头函数,因为箭头函数不能new。

基础

模板语法

attribute

{{}}不能在 HTML attributes 中使用。想要响应式的绑定一个 attribute,应该使用v-bind指令是:

1
<div v-bind:id="dynamicId"></div>

因为v-bind非常常用,我们提供了特定的简写语法:

1
<div :id="dynamicId"></div>

使用 js 表达式

实际上,vue 在所有的数据绑定中都支持完整的 js 表达式。

每个帮顶仅支持单一表达式,也就是一段能够被求值的 js 代码。一个简单的判断是是否可以合法的写在return后面。

绑定在表达式中的方法在组件每次更新时都会被重新调用,因此应该产生任何副作用,比如改变数据或触发异步操作。

受限的全局访问:

模板中的表达式将被沙盒化,仅能够访问到有限的全局对象列表。该列表中会暴露常用的内置全局对象,比如 MathDate

没有显式包含在列表中的全局对象将不能在模板内表达式中访问,例如用户附加在 window 上的属性。然而,你也可以自行在 app.config.globalProperties 上显式地添加它们,供所有的 Vue 表达式使用。

指令

指令是带有v-前缀的特殊 attribute,vue 提供了许多内置指令,包括上面提到的v-bind

指令 attribute 的期望值是一个 js 表达式(除了v-forv-onv-slot 这几个少数的例外)。一个指令的任务是在其表达式的值变化时响应式地更新 DOM。

v-if为例:

1
<p v-if="seen">Now you see me</p>

这里,v-if 指令会基于表达式 seen 的值的真假来移除/插入该 <p> 元素。

响应式基础

reactive()

我们可以使用 reactive() 函数创建一个响应式对象或数组:

1
2
3
import { reactive } from "vue"

const state = reactive({ count: 0 })

<script setup>

要在组件模板中使用响应式状态,需要在 setup() 函数中定义并返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script>
import { reactive } from "vue"

export default {
setup() {
const state = reactive({ count: 0 })

function increment() {
state.count++
}

// 不要忘记同时暴露 increment 函数
return {
state,
increment,
}
},
}
</script>

在 setup() 函数中手动暴露大量的状态和方法非常繁琐。幸运的是,我们可以通过使用构建工具来简化该操作。当使用单文件组件(SFC)时,我们可以使用 <script setup> 来大幅度地简化代码。

1
2
3
4
5
6
7
8
9
<script setup>
import { reactive } from "vue"

const state = reactive({ count: 0 })

function increment() {
state.count++
}
</script>

<script setup> 中的顶层的导入和变量声明可在同一组件的模板中直接使用。你可以理解为模板中的表达式和 <script setup> 中的代码处在同一个作用域中。

DOM 更新时机

当你更改响应式状态后,DOM 会自动更新。然而,你得注意 DOM 的更新并不是同步的。相反,Vue 将缓冲它们直到更新周期的 “下个时机” 以确保无论你进行了多少次状态更改,每个组件都只更新一次。

若要等待一个状态改变后的 DOM 更新完成,你可以使用 nextTick() 这个全局 API:

1
2
3
4
5
6
7
8
9
import { nextTick } from "vue"

function increment() {
state.count++
nextTick(() => {
// DOM更新后执行...
// 访问更新后的DOM
})
}

深层响应性

在 Vue 中,状态都是默认深层响应式的。这意味着即使在更改深层次的对象或数组,你的改动也能被检测到。

1
2
3
4
5
6
7
8
9
10
11
12
import { reactive } from "vue"

const obj = reactive({
nested: { count: 0 },
arr: ["foo", "bar"],
})

function mutateDeeply() {
// 以下都会按照期望工作
obj.nested.count++
obj.arr.push("baz")
}

你也可以直接创建一个浅层响应式对象。它们仅在顶层具有响应性,一般仅在某些特殊场景中需要。

响应式代理 vs 原始对象

值得注意的是,reactive() 返回的是一个原始对象的 Proxy,它和原始对象是不相等的:

1
2
3
4
5
const raw = {}
const proxy = reactive(raw)

// 代理对象和原始对象不是全等的
console.log(proxy === raw) // false

只有代理对象是响应式的,更改原始对象不会触发更新。因此,使用 vue 的响应式系统的最佳实践是 仅使用你声明对象的代理版本

为保证访问代理的一致性,对同一个原始对象调用reactive()总是返回同样的代理对象,而对一个已存在的代理对象调用reactive()会返回其本身:

1
2
3
4
5
// 在同一个对象上调用 reactive() 会返回相同的代理
console.log(reactive(raw) === proxy) // true

// 在一个代理上调用 reactive() 会返回它自己
console.log(reactive(proxy) === proxy) // true

这个规则对嵌套对象也适用。依靠深层响应性,响应式对象内的嵌套对象依然是代理

1
2
3
4
5
6
const proxy = reactive({})

const raw = {}
proxy.nested = raw

console.log(proxy.nested === raw) // false

reactive()的局限性

因为 js 没有可以作用于所有值类型的“引用”机制。所以reactive() API 有两条限制:

  1. 仅对对象类型有效(对象、数组和 MapSet 这样的集合类型),而对 stringnumberboolean 这样的 原始类型 无效。
  2. 必须始终保持对响应式对象的相同引用。不可以随意地“替换”一个响应式对象,因为这将导致对初始引用的响应性连接丢失:
1
2
3
4
let state = reactive({ count: 0 })

// 上面的引用({{count: 0})将不再被追踪(响应性连接已丢失!)
state = reactive({ count: 1 })

同时这也意味着当我们将响应式对象的属性赋值或解构至本地变量时,或是将该属性传入一个函数时,我们会失去响应性:

1
2
3
4
5
6
7
8
9
const state = reactive({ count: 0 })

let n = state.count // n是一个局部变量,同 state.count 失去响应性连接
n++ // 不影响原始的state.count

let { count } = state // count也和state.count失去了响应性连接
count++ // 不会影响到原始的state

callSomeFunction(state.count) // 该函数接收一个普通数字,并且将无法跟踪state.count的变化

ref()

js 没有可以作用于所有值类型的“引用”机制,为此,vue 提供了一个ref()方法来允许我们创建可以使用任何值类型的响应式 ref:

1
2
3
import { ref } from "vue"

const count = ref(0)

ref()将传入参数的值包装为一个带.value属性的 ref 对象:

1
2
3
4
5
6
7
const count: Ref<number> = ref(0)

console.log(count) // { value: 0 }
console.log(count.value) // 0

count.value++
console.log(count.value) // 1

和响应式对象的属性类似,ref 的 .value 属性也是响应式的。同时,当值为对象类型时,会用 reactive() 自动转换它的 .value

一个包含对象类型值的 ref 可以响应式地替换整个对象:

1
2
3
4
const objectRef = ref({ count: 0 })

// 这是响应式的替换
objectRef.value = { count: 1 }

ref 被传递给函数或是从一般对象上被解构时,不会丢失响应性:

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
foo: ref(1),
bar: ref(2),
}

// 该函数接收一个 ref
// 需要通过 .value 取值
// 但它会保持响应性
callSomeFunction(obj.foo)

// 仍然是响应式的
const { foo, bar } = obj

简言之,ref() 让我们能创造一种对任意值的 “引用”,并能够在不丢失响应性的前提下传递这些引用。这个功能很重要,因为它经常用于将逻辑提取到 组合函数 中。

ref 在模板中的解包

当 ref 在模板中作为顶层属性被访问时,它们会自动“解包”,所以不需要使用.value,示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup>
import { ref } from "vue"

const count = ref(0)

function increment() {
count.value++
}
</script>

<template>
<button @click="increment">
{{ count }}
<!-- 无需 .value -->
</button>
</template>

请注意,仅当 ref 是模板渲染上下文的顶层属性时才适用自动“解包”。 例如:

1
const object = { foo: ref(1) }

下面的表达式将不会像预期的那样工作:

1
2
3
4
5
{
{
object.foo + 1
}
}

因为此时 ref 所在的上下文是object而不是模板。我们可以将foo提取出来,这样 ref 的上下文就是模板了:

1
const { foo } = object
1
2
3
4
5
{
{
foo + 1
}
}

需要注意的是,如果是下面这种情况,直接渲染,不参与计算,则也会被自动解包:

1
2
3
4
5
{
{
object.foo
}
} // 相当于 {{ object.foo.value }}

ref 在响应式对象中的解包

当一个ref被嵌套在一个响应式对象中,作为属性被访问或更改时,它会自动解包,因此会表现的和一般属性一样:

1
2
3
4
5
6
7
8
9
const count = ref(0)
const state = reactive({
count,
})

console.log(state.count) // 0

state.count = 1
console.log(count.value) // 1

如果将一个新的 ref 赋值给一个关联了已有 ref 的属性,那么它会替换掉旧的 ref:

1
2
3
4
5
6
const otherCount = ref(2)

state.count = otherCount
console.log(state.count) // 2
// 原始 ref 现在已经和 state.count 失去了联系
console.log(count.value) // 1

只有当嵌套在一个深层响应式对象内时,才会发生 ref 解包,当其作为浅层响应式对象的属性被访问时不会解包。

数组和集合类型的 ref 解包

跟响应式对象不同,当 ref 作为响应式数组或像 Map 这种原生集合类型的元素被访问时,不会进行解包。

1
2
3
4
5
6
7
const books = reactive([ref("Vue 3 Guide")])
// 这里需要 .value
console.log(books[0].value)

const map = reactive(new Map([["count", ref(0)]]))
// 这里需要 .value
console.log(map.get("count").value)

计算属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script lang="ts" setup>
import { reactive, computed } from "vue"

const author = reactive({
name: "John Doe",
books: ["Vue 2 - Advanced Guide", "Vue 3 - Basic Guide", "Vue 4 - The Mystery"],
})

// 一个计算属性 ref
const publishedBooksMessage = computed<"Yes" | "No">(() => {
return author.books.length > 0 ? "Yes" : "No"
})
</script>

<template>
<p>Has published books:</p>
<span>{{ publishedBooksMessage }}</span>
</template>

我们在这里定义了一个计算属性 publishedBooksMessagecomputed() 方法期望接收一个 getter 函数,返回值为一个计算属性 ref。和其他一般的 ref 类似,你可以通过 publishedBooksMessage.value 访问计算结果。计算属性 ref 也会在模板中自动解包,因此在模板表达式中引用时无需添加 .value

vue 的计算属性会自动追踪响应式依赖。它会检测到 publishedBooksMessage 依赖于 author.books,所以当 author.books 改变时,任何依赖于 publishedBooksMessage 的绑定都会同时更新。

计算属性缓存 vs 方法

你可能注意到我们在表达式中像这样调用一个函数也会获得和计算属性相同的结果:

1
<p>{{ calculateBooksMessage() }}</p>
1
2
3
4
// 组件中
function calculateBooksMessage() {
return author.books.length > 0 ? "Yes" : "No"
}

若我们将同样的函数定义为一个方法而不是计算属性,两种方式在结果上确实是完全相同的,然而,不同之处在于计算属性值会基于其响应式依赖被缓存。一个计算属性仅会在其响应式依赖更新时才重新计算。这意味着只要 author.books 不改变,无论多少次访问 publishedBooksMessage 都会立即返回先前的计算结果,而不用重复执行 getter 函数。

这也解释了为什么下面的计算属性永远不会更新,因为 Date.now() 并不是一个响应式依赖:

1
const now = computed(() => Date.now())

相比之下,方法调用总是会在重渲染发生时再次执行函数。

为什么需要缓存呢?想象一下我们有一个非常耗性能的计算属性 list,需要循环一个巨大的数组并做许多计算逻辑,并且可能也有其他计算属性依赖于 list。没有缓存的话,我们会重复执行非常多次 list 的 getter,然而这实际上没有必要!如果你确定不需要缓存,那么也可以使用方法调用。

可写计算属性

计算属性默认是只读的。当你尝试修改一个计算属性时,你会收到一个运行时警告。只在某些特殊场景中你可能才需要用到“可写”的属性,你可以通过同时提供 getter 和 setter 来创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup>
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

const fullName = computed({
// getter
get() {
return firstName.value + ' ' + lastName.value
},
// setter
set(newValue) {
// 注意:我们这里使用的是解构赋值语法
[firstName.value, lastName.value] = newValue.split(' ')
}
})
</script>

现在当你再运行 fullName.value = 'John Doe' 时,setter 会被调用而 firstNamelastName 会随之更新。

最佳实践

Getter 不应有副作用

计算属性的 getter 应只做计算而没有任何其他的副作用,这一点非常重要,请务必牢记。举例来说,不要在 getter 中做异步请求或者更改 DOM!一个计算属性的声明中描述的是如何根据其他值派生一个值。因此 getter 的职责应该仅为计算和返回该值。在之后的指引中我们会讨论如何使用监听器根据其他响应式状态的变更来创建副作用。

避免直接修改计算属性值

从计算属性返回的值是派生状态。可以把它看作是一个“临时快照”,每当源状态发生变化时,就会创建一个新的快照。更改快照是没有意义的,因此计算属性的返回值应该被视为只读的,并且永远不应该被更改——应该更新它所依赖的源状态以触发新的计算。

Class 与 Style 绑定

因为 classstyle 都是 attribute,我们可以和其他 attribute 一样使用 v-bind 将它们和动态的字符串绑定。但是,在处理比较复杂的绑定时,通过拼接生成字符串是麻烦且易出错的。因此,Vue 专门为 classstylev-bind 用法提供了特殊的功能增强。除了字符串外,表达式的值也可以是对象或数组。

绑定 HTML class

绑定对象

:classv-bind:class 的缩写。

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

也可以直接绑定一个对象:

1
2
3
4
const classObject = reactive({
active: true,
"text-danger": false,
})
1
<div :class="classObject"></div>

也可以绑定一个返回对象的计算属性。这是一个常见且很有用的技巧:

1
2
3
4
5
6
7
const isActive = ref(true)
const error = ref(null)

const classObject = computed(() => ({
active: isActive.value && !error.value,
"text-danger": error.value && error.value.type === "fatal",
}))
1
<div :class="classObject"></div>
绑定数组

我们可以给 :class 绑定一个数组来渲染多个 CSS class:

1
2
const activeClass = ref("active")
const errorClass = ref("text-danger")
1
<div :class="[activeClass, errorClass]"></div>

渲染的结果是:

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

如果你也想在数组中有条件地渲染某个 class,你可以使用三元表达式:

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

然而,这可能在有多个依赖条件的 class 时会有些冗长。因此也可以在数组中嵌套对象:

1
<div :class="[{ active: isActive }, errorClass]"></div>
在组件上使用
1
2
3
4
5
6
7
8
<!-- 子组件模板 -->
<p class="foo bar">Hi!</p>

<!-- 在使用组件时 -->
<MyComponent class="baz boo" />

<!-- 渲染出的 HTML 为 -->
<p class="foo bar baz boo">Hi</p>

绑定内联样式

绑定对象
1
2
const activeColor = ref("red")
const fontSize = ref(30)
1
<div :style="{ 'font-size': fontSize + 'px' }"></div>

直接绑定一个样式对象通常是一个好主意,这样可以使模板更加简洁:

1
2
3
4
const styleObject = reactive({
color: "red",
fontSize: "13px",
})
1
<div :style="styleObject"></div>

同样的,如果样式对象需要更复杂的逻辑,也可以使用返回样式对象的计算属性。

绑定数组

我们还可以给 :style 绑定一个包含多个样式对象的数组。这些对象会被合并后渲染到同一元素上:

1
<div :style="[baseStyles, overridingStyles]"></div>
自动前缀
样式多值

条件渲染

v-if

1
2
3
4
<div v-if="type === 'A'">A</div>
<div v-else-if="type === 'B'">B</div>
<div v-else-if="type === 'C'">C</div>
<div v-else>Not A/B/C</div>

一个 v-else 元素必须跟在一个 v-if 或者 v-else-if 元素后面,否则它将不会被识别。

v-ifv-elsev-else-if 也可以在 <template> 上使用。

1
2
3
4
5
<template v-if="ok">
<h1>Title</h1>
<p>Paragraph 1</p>
<p>Paragraph 2</p>
</template>

v-show

另一个可以用来按条件显示一个元素的指令是 v-show。其用法基本一样:

1
<h1 v-show="ok">Hello!</h1>

不同之处在于 v-show 会在 DOM 渲染中保留该元素;v-show 仅切换了该元素上名为 display 的 CSS 属性。

v-show 不支持在 <template> 元素上使用,也不能和 v-else 搭配使用。

v-ifv-show

v-if 是“真实的”按条件渲染,因为它确保了在切换时,条件区块内的事件监听器和子组件都会被销毁与重建。

v-if 也是惰性的:如果在初次渲染时条件值为 false,则不会做任何事。条件区块只有当条件首次变为 true 时才被渲染。

相比之下,v-show 简单许多,元素无论初始条件如何,始终会被渲染,只有 CSS display 属性会被切换。

总的来说,v-if 有更高的切换开销,而 v-show 有更高的初始渲染开销。因此,如果需要频繁切换,则使用 v-show 较好;如果在运行时绑定条件很少改变,则 v-if 会更合适。

列表渲染

v-for

1
const items = ref([{ message: "Foo" }, { message: "Bar" }])
1
<li v-for="item in items">{{ item.message }}</li>

你也可以使用 of 作为分隔符来替代 in,这更接近 JavaScript 的迭代器语法:

1
<div v-for="item of items"></div>

v-for 与对象

你也可以使用 v-for 来遍历一个对象的所有属性。遍历的顺序会基于对该对象调用 Object.keys() 的返回值来决定。

1
2
3
4
5
const myObject = reactive({
title: "How to do lists in Vue",
author: "Jane Doe",
publishedAt: "2016-04-10",
})
1
2
3
4
5
6
7
8
9
<ul>
<li v-for="value in myObject">{{ value }}</li>
</ul>

<!-- 可以通过提供第二个参数表示属性名 (例如 key) -->
<li v-for="(value, key) in myObject">{{ key }}: {{ value }}</li>

<!-- 第三个参数表示位置索引 -->
<li v-for="(value, key, index) in myObject">{{ index }}. {{ key }}: {{ value }}</li>

在 v-for 里使用范围值

v-for 可以直接接受一个整数值。在这种用例中,会将该模板基于 1...n 的取值范围重复多次。

1
<span v-for="n in 10">{{ n }}</span>

注意此处 n 的初值是从 1 开始而非 0

<template> 上的 v-for

与模板上的 v-if 类似,你也可以在 <template> 标签上使用 v-for 来渲染一个包含多个元素的块。例如:

1
2
3
4
5
6
<ul>
<template v-for="item in items">
<li>{{ item.msg }}</li>
<li class="divider" role="presentation"></li>
</template>
</ul>

v-forv-if

警告:

同时使用 v-ifv-for不推荐的,因为这样二者的优先级不明显。请查看风格指南获得更多信息。

当它们同时存在于一个节点上时,v-ifv-for 的优先级更高。这意味着 v-if 的条件将无法访问到 v-for 作用域内定义的变量别名:

1
2
3
4
5
<!--
这会抛出一个错误,因为属性 todo 此时
没有在该实例上定义
-->
<li v-for="todo in todos" v-if="!todo.isComplete">{{ todo.name }}</li>

在外新包装一层 <template> 再在其上使用 v-for 可以解决这个问题 (这也更加明显易读):

1
2
3
<template v-for="todo in todos">
<li v-if="!todo.isComplete">{{ todo.name }}</li>
</template>

通过 key 管理状态

vue 默认按照“就地更新”的策略来更新通过 v-for 渲染的元素列表。当数据项的顺序改变时,vue 不会随之移动 DOM 元素的顺序,而是就地更新每个元素,确保它们在原本指定的索引位置上渲染。

默认模式是高效的,但只适用于列表渲染输出的结果不依赖子组件状态或者临时 DOM 状态 (例如表单输入值) 的情况

为了给 vue 一个提示,以便它可以跟踪每个节点的标识,从而重用和重新排序现有的元素,你需要为每个元素对应的块提供一个唯一的 key attribute:

1
2
3
<div v-for="item in items" :key="item.id">
<!-- 内容 -->
</div>

当你使用 <template v-for> 时,key 应该被放置在这个 <template> 容器上:

1
2
3
<template v-for="todo in todos" :key="todo.name">
<li>{{ todo.name }}</li>
</template>

注意:key 在这里是一个通过 v-bind 绑定的特殊 attribute。请不要和v-for 中使用对象里所提到的对象属性名相混淆。

推荐在任何可行的时候为 v-for 提供一个 key attribute,除非所迭代的 DOM 内容非常简单 (例如:不包含组件或有状态的 DOM 元素),或者你想有意采用默认行为来提高性能。

key 绑定的值期望是一个基础类型的值,例如字符串或 number 类型。不要用对象作为 v-for 的 key。关于 key attribute 的更多用途细节,请参阅 key API 文档

组件上使用v-for

我们可以直接在组件上使用 v-for,和在一般的元素上使用没有区别 (别忘记提供一个 key):

1
<MyComponent v-for="item in items" :key="item.id" />

但是,这不会自动将任何数据传递给组件,因为组件有自己独立的作用域。为了将迭代后的数据传递到组件中,我们还需要传递 props:

1
<MyComponent v-for="(item, index) in items" :item="item" :index="index" :key="item.id" />

不自动将 item 注入组件的原因是,这会使组件与 v-for 的工作方式紧密耦合。明确其数据的来源可以使组件在其他情况下重用。

数组变化侦测

变更方法

vue 能够侦听响应式数组的变更方法,并在它们被调用时触发相关的更新。这些变更方法包括:

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()
替换一个数组

变更方法,顾名思义,就是会对调用它们的原数组进行变更。相对地,也有一些不可变 (immutable) 方法,例如 filter()concat()slice(),这些都不会更改原数组,而总是返回一个新数组。当遇到的是非变更方法时,我们需要将旧的数组替换为新的:

1
2
// `items` 是一个数组的 ref
items.value = items.value.filter(item => item.message.match(/Foo/))

你可能认为这将导致 vue 丢弃现有的 DOM 并重新渲染整个列表——幸运的是,情况并非如此。vue 实现了一些巧妙的方法来最大化对 DOM 元素的重用,因此用另一个包含部分重叠对象的数组来做替换,仍会是一种非常高效的操作。

展示过滤或排序后的结果

有时,我们希望显示数组经过过滤或排序后的内容,而不实际变更或重置原始数据。在这种情况下,你可以创建返回已过滤或已排序数组的计算属性。

举例:

1
2
3
4
5
const numbers = ref([1, 2, 3, 4, 5])

const evenNumbers = computed(() => {
return numbers.value.filter(n => n % 2 === 0)
})
1
<li v-for="n in evenNumbers">{{ n }}</li>

在计算属性不可行的情况下 (例如在多层嵌套的 v-for 循环中),你可以使用以下方法:

1
2
3
4
5
6
7
8
const sets = ref([
[1, 2, 3, 4, 5],
[6, 7, 8, 9, 10],
])

function even(numbers) {
return numbers.filter(number => number % 2 === 0)
}
1
2
3
<ul v-for="numbers in sets">
<li v-for="n in even(numbers)">{{ n }}</li>
</ul>

在计算属性中使用 reverse()sort() 的时候务必小心!这两个方法将变更原始数组,计算函数中不应该这么做。请在调用这些方法之前创建一个原数组的副本:

1
2
- return numbers.reverse()
+ return [...numbers].reverse()

事件处理

监听事件

我们可以使用 v-on 指令 (简写为 @) 来监听 DOM 事件,并在事件触发时执行对应的 JavaScript。用法:v-on:click="methodName"@click="handler"

事件处理器的值可以是:

  1. 内联事件处理器:事件被触发时执行的内联 JavaScript 语句 (与 onclick 类似)。
  2. 方法事件处理器:一个指向组件上定义的方法的属性名或是路径。

内联事件处理器

内联事件处理器通常用于简单场景,例如:

1
const count = ref(0)
1
2
<button @click="count++">Add 1</button>
<p>Count is: {{ count }}</p>

方法事件处理器

随着事件处理器的逻辑变得愈发复杂,内联代码方式变得不够灵活。因此 v-on 也可以接受一个方法名或对某个方法的调用。

举例:

1
2
3
4
5
6
7
8
9
const name = ref("Vue.js")

function greet(event) {
alert(`Hello ${name.value}!`)
// `event` 是 DOM 原生事件
if (event) {
alert(event.target.tagName)
}
}
1
2
<!-- `greet` 是上面定义过的方法名 -->
<button @click="greet">Greet</button>

方法事件处理器会自动接收原生 DOM 事件并触发执行。在上面的例子中,我们能够通过被触发事件的 event.target.tagName 访问到该 DOM 元素。

方法与内联事件判断

模板编译器会通过检查 v-on 的值是否是合法的 JavaScript 标识符或属性访问路径来断定是何种形式的事件处理器。举例来说,foofoo.barfoo['bar'] 会被视为方法事件处理器,而 foo()count++ 会被视为内联事件处理器。

在内联处理器中调用方法

除了直接绑定方法名,你还可以在内联事件处理器中调用方法。这允许我们向方法传入自定义参数以代替原生事件:

1
2
3
function say(message) {
alert(message)
}
1
<button @click="say('hello')">Say hello</button> <button @click="say('bye')">Say bye</button>

在内联事件处理器中访问事件参数

有时我们需要在内联事件处理器中访问原生 DOM 事件。你可以向该处理器方法传入一个特殊的 $event 变量,或者使用内联箭头函数:

1
2
3
4
5
6
7
8
9
<!-- 使用特殊的 $event 变量 -->
<button @click="warn('Form cannot be submitted yet.', $event)">
Submit
</button>

<!-- 使用内联箭头函数 -->
<button @click="e => warn('Form cannot be submitted yet.', e)">
Submit
</button>
1
2
3
4
5
6
7
function warn(message, event) {
// 这里可以访问原生事件
if (event) {
event.preventDefault()
}
alert(message)
}

事件修饰符

在处理事件时调用 event.preventDefault()event.stopPropagation() 是很常见的。尽管我们可以直接在方法内调用,但如果方法能更专注于数据逻辑而不用去处理 DOM 事件的细节会更好。

为解决这一问题,Vue 为 v-on 提供了事件修饰符。修饰符是用 . 表示的指令后缀,包含以下这些:

  • .stop
  • .prevent
  • .self
  • .capture
  • .once
  • .passive
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 单击事件将停止传递 -->
<a @click.stop="doThis"></a>

<!-- 提交事件将不再重新加载页面 -->
<form @submit.prevent="onSubmit"></form>

<!-- 修饰语可以使用链式书写 -->
<a @click.stop.prevent="doThat"></a>

<!-- 也可以只有修饰符 -->
<form @submit.prevent></form>

<!-- 仅当 event.target 是元素本身时才会触发事件处理器 -->
<!-- 例如:事件处理器不来自子元素 -->
<div @click.self="doThat">...</div>

使用修饰符时需要注意调用顺序,因为相关代码是以相同的顺序生成的。因此使用 @click.prevent.self 会阻止元素及其子元素的所有点击事件的默认行为,@click.self.prevent 则只会阻止对元素本身的点击事件的默认行为。

.capture.once.passive 修饰符与原生 addEventListener 事件相对应:

1
2
3
4
5
6
7
8
9
10
<!-- 添加事件监听器时,使用 `capture` 捕获模式 -->
<!-- 例如:指向内部元素的事件,在被内部元素处理前,先被外部处理 -->
<div @click.capture="doThis">...</div>

<!-- 点击事件最多被触发一次 -->
<a @click.once="doThis"></a>

<!-- 滚动事件的默认行为 (scrolling) 将立即发生而非等待 `onScroll` 完成 -->
<!-- 以防其中包含 `event.preventDefault()` -->
<div @scroll.passive="onScroll">...</div>

.passive 修饰符一般用于触摸事件的监听器,可以用来改善移动端设备的滚屏性能

请勿同时使用 .passive.prevent,因为 .passive 已经向浏览器表明了你不想阻止事件的默认行为。如果你这么做了,则 .prevent 会被忽略,并且浏览器会抛出警告。

按键修饰符

在监听键盘事件时,我们经常需要检查特定的按键。Vue 允许在 v-on@ 监听按键事件时添加按键修饰符。

1
2
<!-- 仅在 `key` 为 `Enter` 时调用 `submit` -->
<input @keyup.enter="submit" />

你可以直接使用 KeyboardEvent.key 暴露的按键名称作为修饰符,但需要转为 kebab-case 形式。

1
<input @keyup.page-down="onPageDown" />

在上面的例子中,仅会在 $event.key'PageDown' 时调用事件处理。

按键别名

Vue 为一些常用的按键提供了别名:

  • .enter
  • .tab
  • .delete (捕获“Delete”和“Backspace”两个按键)
  • .esc
  • .space
  • .up
  • .down
  • .left
  • .right
系统按键修饰符

你可以使用以下系统按键修饰符来触发鼠标或键盘事件监听器,只有当按键被按下时才会触发。

  • .ctrl
  • .alt
  • .shift
  • .meta

在 Mac 键盘上,meta 是 Command 键 (⌘)。在 Windows 键盘上,meta 键是 Windows 键 (⊞)。在 Sun 微机系统键盘上,meta 是钻石键 (◆)。在某些键盘上,特别是 MIT 和 Lisp 机器的键盘及其后代版本的键盘,如 Knight 键盘,space-cadet 键盘,meta 都被标记为“META”。在 Symbolics 键盘上,meta 也被标识为“META”或“Meta”。

1
2
3
4
5
<!-- Alt + Enter -->
<input @keyup.alt.enter="clear" />

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

请注意,系统按键修饰符和常规按键不同。与 keyup 事件一起使用时,该按键必须在事件发出时处于按下状态。换句话说,keyup.ctrl 只会在你仍然按住 ctrl 但松开了另一个键时被触发。若你单独松开 ctrl 键将不会触发。

.exact修饰符

.exact 修饰符允许控制触发一个事件所需的确定组合的系统按键修饰符。

1
2
3
4
5
6
7
8
<!-- 当按下 Ctrl 时,即使同时按下 Alt 或 Shift 也会触发 -->
<button @click.ctrl="onClick">A</button>

<!-- 仅当按下 Ctrl 且未按任何其他键时才会触发 -->
<button @click.ctrl.exact="onCtrlClick">A</button>

<!-- 仅当没有按下任何系统按键时触发 -->
<button @click.exact="onClick">A</button>

鼠标按键修饰符

  • .left
  • .right
  • .middle

这些修饰符将处理程序限定为由特定鼠标按键触发的事件。

表单输入与绑定

在前端处理表单时,我们常常需要将表单输入框的内容同步给 JavaScript 中相应的变量。手动连接值绑定和更改事件监听器可能会很麻烦:

1
2
3
<input
:value="text"
@input="event => text = event.target.value">

v-model 指令帮我们简化了这一步骤:

1
<input v-model="text">

注意:

v-model 会忽略任何表单元素上初始的 valuecheckedselected attribute。它将始终将当前绑定的 JavaScript 状态视为数据的正确来源。你应该在 JavaScript 中使用响应式系统的 API 来声明该初始值。

值绑定

有时我们可能希望将该值绑定到当前组件实例上的动态数据。这可以通过使用 v-bind 来实现。此外,使用 v-bind 还使我们可以将选项值绑定为非字符串的数据类型。

复选框

1
<input type="checkbox" v-model="toggle" true-value="yes" false-value="no" />

true-valuefalse-value 是 Vue 特有的 attributes,仅支持和 v-model 配套使用。这里 toggle 属性的值会在选中时被设为 'yes',取消选择时设为 'no'。你同样可以通过 v-bind 将其绑定为其他动态值:

1
<input type="checkbox" v-model="toggle" :true-value="dynamicTrueValue" :false-value="dynamicFalseValue" />

提示:

true-valuefalse-value attributes 不会影响 value attribute,因为浏览器在表单提交时,并不会包含未选择的复选框。为了保证这两个值 (例如:“yes”和“no”) 的其中之一被表单提交,请使用单选按钮作为替代。

修饰符

.lazy

默认情况下,v-model 会在每次 input 事件后更新数据 (IME 拼字阶段的状态例外)。你可以添加 lazy 修饰符来改为在每次 change 事件后更新数据:

1
2
<!-- 在 "change" 事件后同步更新而不是 "input" -->
<input v-model.lazy="msg" />
.number

如果你想让用户输入自动转换为数字,你可以在 v-model 后添加 .number 修饰符来管理输入:

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

如果该值无法被 parseFloat() 处理,那么将返回原始值。

number 修饰符会在输入框有 type="number" 时自动启用。

.trim

如果你想要默认自动去除用户输入内容中两端的空格,你可以在 v-model 后添加 .trim 修饰符:

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

组件上的v-model

HTML 的内置表单输入类型并不总能满足所有需求。幸运的是,我们可以使用 Vue 构建具有自定义行为的可复用输入组件,并且这些输入组件也支持 v-model!要了解更多关于此的内容,请在组件指引中阅读配合 v-model 使用

生命周期钩子

每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到 DOM,以及在数据改变时更新 DOM。在此过程中,它也会运行被称为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码。

侦听器

计算属性允许我们声明性地计算衍生值。然而在有些情况下,我们需要在状态变化时执行一些“副作用”:例如更改 DOM,或是根据异步操作的结果去修改另一处的状态。

侦听器 和 计算属性

有“副作用”,使用侦听器;没有“副作用”,使用计算属性。

watch()

在组合式 API 中,我们可以使用 watch 函数在每次响应式状态发生变化时触发回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script setup>
import { ref, watch } from "vue"

const question = ref("")
const answer = ref("Questions usually contain a question mark. ;-)")

// 可以直接侦听一个 ref
watch(question, async (newQuestion, oldQuestion) => {
if (newQuestion.indexOf("?") > -1) {
answer.value = "Thinking..."
try {
const res = await fetch("https://yesno.wtf/api")
answer.value = (await res.json()).answer
} catch (error) {
answer.value = "Error! Could not reach the API. " + error
}
}
})
</script>

<template>
<p>
Ask a yes/no question:
<input v-model="question" />
</p>
<p>{{ answer }}</p>
</template>

watch 的第一个参数可以是不同形式的“数据源”:它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const x = ref(0)
const y = ref(0)

// 单个 ref
watch(x, newX => {
console.log(`x is ${newX}`)
})

// getter 函数
watch(
() => x.value + y.value,
sum => {
console.log(`sum of x + y is: ${sum}`)
}
)

// 多个来源组成的数组
watch([x, () => y.value], ([newX, newY]) => {
console.log(`x is ${newX} and y is ${newY}`)
})

注意,你不能直接侦听响应式对象的属性值,例如:

1
2
3
4
5
6
const obj = reactive({ count: 0 })

// 错误,因为 watch() 得到的参数是一个 number
watch(obj.count, count => {
console.log(`count is: ${count}`)
})

这里需要用一个返回该属性的 getter 函数:

1
2
3
4
5
6
7
// 提供一个 getter 函数
watch(
() => obj.count,
count => {
console.log(`count is: ${count}`)
}
)

深层侦听器

直接给 watch() 传入一个响应式对象,会隐式地创建一个深层侦听器——该回调函数在所有嵌套的变更时都会被触发:

1
2
3
4
5
6
7
8
9
const obj = reactive({ count: 0 })

watch(obj, (newValue, oldValue) => {
// 在嵌套的属性变更时触发
// 注意:`newValue` 此处和 `oldValue` 是相等的
// 因为它们是同一个对象!
})

obj.count++

相比之下,一个返回响应式对象的 getter 函数,只有在返回不同的对象时,才会触发回调:

1
2
3
4
5
6
watch(
() => state.someObject,
() => {
// 仅当 state.someObject 被替换时触发
}
)

你也可以给上面这个例子显式地加上 deep 选项,强制转成深层侦听器:

1
2
3
4
5
6
7
8
watch(
() => state.someObject,
(newValue, oldValue) => {
// 注意:`newValue` 此处和 `oldValue` 是相等的
// *除非* state.someObject 被整个替换了
},
{ deep: true }
)

谨慎使用:

深度侦听需要遍历被侦听对象中的所有嵌套的属性,当用于大型数据结构时,开销很大。因此请只在必要时才使用它,并且要留意性能。

即时回调的侦听器

watch 默认是懒执行的:仅当数据源变化时,才会执行回调。但在某些场景中,我们希望在创建侦听器时,立即执行一遍回调。举例来说,我们想请求一些初始数据,然后在相关状态更改时重新请求数据。

我们可以通过传入 immediate: true 选项来强制侦听器的回调立即执行:

1
2
3
4
5
6
7
watch(
source,
(newValue, oldValue) => {
// 立即执行,且当 `source` 改变时再次执行
},
{ immediate: true }
)

watchEffect()

下面的例子中,在每当 todoId 的引用发生变化时使用侦听器来加载一个远程资源:

1
2
3
4
5
6
7
8
9
10
11
const todoId = ref(1)
const data = ref(null)

watch(
todoId,
async () => {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${todoId.value}`)
data.value = await response.json()
},
{ immediate: true }
)

侦听的数据源是todoId,而回调中也使用到了todoId,这种情况是很常见的。

我们可以用 watchEffect 函数 来简化上面的代码。watchEffect() 允许我们自动跟踪回调的响应式依赖。上面的侦听器可以重写为:

1
2
3
4
watchEffect(async () => {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${todoId.value}`)
data.value = await response.json()
})

这个例子中,回调会立即执行,不需要指定 immediate: true。在执行期间,它会自动追踪 todoId.value 作为依赖(和计算属性类似)。每当 todoId.value 变化时,回调会再次执行。有了 watchEffect(),我们不再需要明确传递 todoId 作为源值。

对于这种只有一个依赖项的例子来说,watchEffect() 的好处相对较小。但是对于有多个依赖项的侦听器来说,使用 watchEffect() 可以消除手动维护依赖列表的负担。此外,如果你需要侦听一个嵌套数据结构中的几个属性,watchEffect() 可能会比深度侦听器更有效,因为它将只跟踪回调中被使用到的属性,而不是递归地跟踪所有的属性。

提示:

watchEffect 仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

watchwatchEffect

watchwatchEffect 都能响应式地执行有副作用的回调。它们之间的主要区别是追踪响应式依赖的方式:

  • watch 只追踪明确侦听的数据源。它不会追踪任何在回调中访问到的东西。另外,仅在数据源确实改变时才会触发回调。watch 会避免在发生副作用时追踪依赖,因此,我们能更加精确地控制回调函数的触发时机。
  • watchEffect,则会在副作用发生期间追踪依赖。它会在同步执行过程中,自动追踪所有能访问到的响应式属性。这更方便,而且代码往往更简洁,但有时其响应性依赖关系会不那么明确。

回调的触发时机

当你更改了响应的状态,它可能会同时触发 vue 组件更新和侦听器回调。

默认情况下,用户创建的侦听器回调,都会在 vue 组件更新之前被调用。这意味着你在侦听器回调中访问的 DOM 将是被 vue 更新的状态。

如果想在侦听器回调中能访问被 vue 更新之后的 DOM,你需要指明flush 'post'选项:

1
2
3
4
5
6
7
watch(source, callback, {
flush: "post",
})

watchEffect(callback, {
flush: "post",
})

后置刷新的watchEffect()有个更方便的别名watchPostEffect()

1
2
3
4
5
import { watchPostEffect } from "vue"

watchPostEffect(() => {
/* 在 Vue 更新后执行 */
})

停止侦听器

setup()<script setup> 中用同步语句创建的侦听器,会自动绑定到宿主组件实例上,并且会在宿主组件卸载时自动停止。因此,在大多数情况下,你无需关心怎么停止一个侦听器。

一个关键点是,侦听器必须用同步语句创建:如果用异步回调创建一个侦听器,那么它不会绑定到当前组件上,你必须手动停止它,以防内存泄漏。如下方这个例子:

1
2
3
4
5
6
7
8
9
10
11
<script setup>
import { watchEffect } from "vue"

// 它会自动停止
watchEffect(() => {})

// ...这个则不会!
setTimeout(() => {
watchEffect(() => {})
}, 100)
</script>

要手动停止一个侦听器,请调用 watchwatchEffect 返回的函数:

1
2
3
4
const unwatch = watchEffect(() => {})

// ...当该侦听器不再需要时
unwatch()

注意,需要异步创建侦听器的情况很少,请尽可能选择同步创建。如果需要等待一些异步数据,你可以使用条件式的侦听逻辑:

1
2
3
4
5
6
7
8
// 需要异步请求得到的数据
const data = ref(null)

watchEffect(() => {
if (data.value) {
// 数据加载后执行某些操作...
}
})

模板引用

虽然 Vue 的声明性渲染模型为你抽象了大部分对 DOM 的直接操作,但在某些情况下,我们仍然需要直接访问底层 DOM 元素。要实现这一点,我们可以使用特殊的 ref attribute:

1
<input ref="input">

ref 是一个特殊的 attribute,和 v-for 章节中提到的 key 类似。它允许我们在一个特定的 DOM 元素或子组件实例被挂载后,获得对它的直接引用。这可能很有用,比如说在组件挂载时将焦点设置到一个 input 元素上,或在一个元素上初始化一个第三方库。

访问模板引用

为了通过组合式 API 获得该模板引用,我们需要声明一个同名的 ref:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup>
import { ref, onMounted } from "vue"

// 声明一个 ref 来存放该元素的引用
// 必须和模板里的 ref 同名
const input = ref(null)

onMounted(() => {
input.value.focus()
})
</script>

<template>
<input ref="input" />
</template>

注意,你只可以在组件挂载后才能访问模板引用。如果你想在模板中的表达式上访问 input,在初次渲染时会是 null。这是因为在初次渲染前这个元素还不存在呢!

如果你需要侦听一个模板引用 ref 的变化,确保考虑到其值为 null 的情况:

1
2
3
4
5
6
7
watchEffect(() => {
if (input.value) {
input.value.focus()
} else {
// 此时还未挂载,或此元素已经被卸载(例如通过 v-if 控制)
}
})

v-for中的模板引用

当在 v-for 中使用模板引用时,对应的 ref 中包含的值是一个数组,它将在元素被挂载后包含对应整个列表的所有元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script setup>
import { ref, onMounted } from "vue"

const list = ref([
/* ... */
])

const itemRefs = ref([])

onMounted(() => console.log(itemRefs.value))
</script>

<template>
<ul>
<li v-for="item in list" ref="itemRefs">
{{ item }}
</li>
</ul>
</template>

应该注意的是,ref 数组并不保证与源数组相同的顺序。

函数模板引用

除了使用字符串值作名字,ref attribute 还可以绑定为一个函数,会在每次组件更新时都被调用。该函数会收到元素引用作为其第一个参数:

1
<input :ref="(el) => { /* 将 el 赋值给一个数据属性或 ref 变量 */ }" />

注意我们这里需要使用动态的 :ref 绑定才能够传入一个函数。当绑定的元素被卸载时,函数也会被调用一次,此时的 el 参数会是 null。你当然也可以绑定一个组件方法而不是内联函数。

组件上的 ref

模板引用也可以被用在一个子组件上。这种情况下引用中获得的值是组件实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup>
import { ref, onMounted } from "vue"
import Child from "./Child.vue"

const child = ref(null)

onMounted(() => {
// child.value 是 <Child /> 组件的实例
})
</script>

<template>
<Child ref="child" />
</template>

如果一个子组件使用的是选项式 API 或没有使用 <script setup>,被引用的组件实例和该子组件的 this 完全一致,这意味着父组件对子组件的每一个属性和方法都有完全的访问权。这使得在父组件和子组件之间创建紧密耦合的实现细节变得很容易,当然也因此,应该只在绝对需要时才使用组件引用。大多数情况下,你应该首先使用标准的 props 和 emit 接口来实现父子组件交互。

有一个例外的情况,使用了 <script setup> 的组件是默认私有的:一个父组件无法访问到一个使用了 <script setup> 的子组件中的任何东西,除非子组件在其中通过 defineExpose 宏显式暴露:

1
2
3
4
5
6
7
8
9
10
11
12
<script setup>
import { ref } from "vue"

const a = 1
const b = ref(2)

// 像 defineExpose 这样的编译器宏不需要导入
defineExpose({
a,
b,
})
</script>

当父组件通过模板引用获取到了该组件的实例时,得到的实例类型为 { a: number, b: number } (ref 都会自动解包,和一般的实例一样)。

组件基础

传递 props

props 是一种特别的 attributes,你可以在组件上声明注册。要传递给博客文章组件一个标题,我们必须在组件的 props 列表上声明它。这里要用到 defineProps 宏:

1
2
3
4
5
6
7
8
<!-- BlogPost.vue -->
<script setup>
defineProps(["title"])
</script>

<template>
<h4>{{ title }}</h4>
</template>

defineProps 是一个仅 <script setup> 中可用的编译宏命令,并不需要显式地导入。声明的 props 会自动暴露给模板。defineProps 会返回一个对象,其中包含了可以传递给组件的所有 props:

1
2
const props = defineProps(["title"])
console.log(props.title)

监听事件

父组件可以通过 v-on@ 来选择性地监听子组件上抛的事件,就像监听原生 DOM 事件那样:

1
<BlogPost ... @enlarge-text="postFontSize += 0.1" />

子组件可以通过调用内置的 $emit 方法,通过传入事名称来抛出一个事件:

1
2
3
4
5
6
7
<!-- BlogPost.vue, 省略了 <script> -->
<template>
<div class="blog-post">
<h4>{{ title }}</h4>
<button @click="$emit('enlarge-text')">Enlarge text</button>
</div>
</template>

因为有了 @enlarge-text="postFontSize += 0.1" 的监听,父组件会接收这一事件,从而更新 postFontSize 的值。

我们可以通过 defineEmits 宏来声明需要抛出的事件:

1
2
3
4
5
<!-- BlogPost.vue -->
<script setup>
defineProps(["title"])
defineEmits(["enlarge-text"])
</script>

这声明了一个组件可能触发的所有事件,还可以对事件的参数进行验证。同时,这还可以让 Vue 避免将它们作为原生事件监听器隐式地应用于子组件的根元素。

defineProps 类似,defineEmits 仅可用于 <script setup> 之中,并且不需要导入,它返回一个等同于 $emit 方法的 emit 函数。它可以被用于在组件的 <script setup> 中抛出事件,因为此处无法直接访问 $emit

1
2
3
4
5
<script setup>
const emit = defineEmits(["enlarge-text"])

emit("enlarge-text")
</script>

深入组件

注册

一个 Vue 组件在使用前需要先被“注册”,这样 Vue 才能在渲染模板时找到其对应的实现。组件注册有两种方式:全局注册和局部注册。

全局注册

我们可以使用 Vue 应用实例app.component() 方法,让组件在当前 Vue 应用中全局可用。

1
2
3
import MyComponent from "./App.vue"

app.component("MyComponent", MyComponent)

app.component() 方法可以被链式调用:

1
app.component("ComponentA", ComponentA).component("ComponentB", ComponentB).component("ComponentC", ComponentC)

全局注册的组件可以在此应用的任意组件的模板中使用。并且相互可以在彼此内部使用。

局部注册

局部注册的组件需要在使用它的父组件中显式导入,并且只能在该父组件中使用。它的优点是使组件之间的依赖关系更加明确,并且对 tree-shaking 更加友好。

组件名格式

在 SFC 中,推荐为子组件使用PascalCase的标签名,以此来和原声的 HTML 元素作区分。

但是,PascalCase 的标签名在 DOM 模板中是不可用的,详情参见 DOM 模板解析注意事项,在这种情况下,需要使用 kebab-case 形式。

什么是 DOM 模板?就是直接写在 DOM 中的模板,会被浏览器直接解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE <html>
<head>
<meta charset="utf-8">
<title>Vue Component</title>
</head>
<body>
<div id="app">
<!-- 在 HTML 中是 kebab-case (短横线命名) 的会被渲染 -->
<my-component></my-component>
<my-Component></my-Component>
<My-component></My-component>
<My-Component></My-Component>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script>
// 注册时:PascalCase (首字母大写命名)、camelCase (驼峰命名)、kebab-case (短横线命名) 都可以
Vue.component('MyComponent', {
template: '<div>Hello Vue</div>'
});
new Vue ({
el: '#app'
});
</script>
</html>

<my-component></my-component> 就是 DOM 模板。

props

props 声明

一个组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传 attribute (关于透传 attribute,我们会在专门的章节中讨论)。

1
2
3
4
5
<script setup>
const props = defineProps(["foo"])

console.log(props.foo)
</script>

如果使用了 ts,也可以这么声明:

1
2
3
4
5
6
7
8
<script setup lang="ts">
interface Props {
foo: string
bar?: number
}

const props = defineProps<Props>()
</script>

这被称之为“基于类型的声明”。感觉怪怪的。

当使用基于类型的声明时,我们失去了为 props 声明默认值的能力。这可以通过 withDefaults 编译器宏解决:

1
2
3
4
5
6
7
8
9
export interface Props {
msg?: string
labels?: string[]
}

const props = withDefaults(defineProps<Props>(), {
msg: "hello",
labels: () => ["one", "two"],
})

传递 prop 细节

prop 名字格式

prop 名字使用 camelCase 形式:

1
2
3
defineProps({
greetingMessage: String,
})

然而对于传递 props 来说,使用 camelCase 并没有太多优势,因此我们推荐更贴近 HTML 的书写风格,使用 kebab-case 形式:

1
<MyComponent greeting-message="hello" />
静态 和 动态 prop

静态:

1
<BlogPost title="My journey with Vue" />

动态绑定:

1
2
3
4
5
<!-- 根据一个变量的值动态传入 -->
<BlogPost :title="post.title" />

<!-- 根据一个更复杂表达式的值动态传入 -->
<BlogPost :title="post.title + ' by ' + post.author.name" />
传递不同的值类型

不仅仅是字符串,实际上任何类型的值都可以作为 props 的值被传递。

Number:

1
2
3
4
5
6
<!-- 虽然 `42` 是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :likes="42" />

<!-- 根据一个变量的值动态传入 -->
<BlogPost :likes="post.likes" />

Boolean:

1
2
3
4
5
6
7
8
9
<!-- 仅写上 prop 但不传值,会隐式转换为 `true` -->
<BlogPost is-published />

<!-- 虽然 `false` 是静态的值,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :is-published="false" />

<!-- 根据一个变量的值动态传入 -->
<BlogPost :is-published="post.isPublished" />

Array:

1
2
3
4
5
6
<!-- 虽然这个数组是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :comment-ids="[234, 266, 273]" />

<!-- 根据一个变量的值动态传入 -->
<BlogPost :comment-ids="post.commentIds" />

Object:

1
2
3
4
5
6
7
8
9
10
11
<!-- 虽然这个对象字面量是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost
:author="{
name: 'Veronica',
company: 'Veridian Dynamics',
}"
/>

<!-- 根据一个变量的值动态传入 -->
<BlogPost :author="post.author" />

使用一个对象绑定多个 prop

如果你想要将一个对象的所有属性都当作 props 传入,你可以使用没有参数的 v-bind,即只使用 v-bind 而非 :prop-name。例如,这里有一个 post 对象:

1
2
3
4
const post = {
id: 1,
title: "My Journey with Vue",
}
1
<BlogPost v-bind="post" />

等价于:

1
<BlogPost :id="post.id" :title="post.title" />

单向数据流

所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。

另外,每次父组件更新后,所有的子组件中的 props 都会被更新到最新值,这意味着你不应该在子组件中去更改一个 prop。若你这么做了,Vue 会在控制台上向你抛出警告。

更改对象 / 数组类型的 props

当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为 JavaScript 的对象和数组是按引用传递,而对 Vue 来说,禁止这样的改动,虽然可能生效,但有很大的性能损耗,比较得不偿失。

这种更改的主要缺陷是它允许了子组件以某种不明显的方式影响父组件的状态,可能会使数据流在将来变得更难以理解。在最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件来通知父组件做出改变。

组件事件

触发与监听事件

在组件的模板表达式中,可以直接使用 $emit 方法触发自定义事件:

1
2
3
4
<!-- MyComponent -->
<button @click="$emit('increaseBy', 1)">
Increase by 1
</button>

父组件监听事件:

1
<MyButton @increase-by="n => (count += n)" />

同样,组件的事件监听器也支持 .once 修饰符:

1
<MyComponent @some-event.once="callback" />

像组件与 prop 一样,事件的名字也提供了自动的格式转换。注意这里我们触发了一个以 camelCase 形式命名的事件,但在父组件中可以使用 kebab-case 形式来监听。与 prop 大小写格式一样,在模板中我们也推荐使用 kebab-case 形式来编写监听器。

提示:

和原生 DOM 事件不一样,组件触发的事件没有冒泡机制。你只能监听直接子组件触发的事件。平级组件或是跨越多层嵌套的组件间通信,应使用一个外部的事件总线,或是使用一个全局状态管理方案

声明触发的事件

组件可以显式地通过 defineEmits() 宏来声明它要触发的事件:

1
2
3
4
5
6
<script setup lang="ts">
const emit = defineEmits<{
(e: "change", id: number): void
(e: "update", value: string): void
}>()
</script>

我们在 <template> 中使用的 $emit 方法不能在组件的 <script setup> 部分中使用,但 defineEmits() 会返回一个相同作用的函数供我们使用:

1
2
3
4
5
6
7
8
9
10
<script setup>
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()

function buttonClick() {
emit('submit')
}
</script>

组件 v-model

v-model 可以在组件上使用以实现双向绑定。

首先让我们回忆一下 v-model 在原生元素上的用法:

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

模板编译器会对 v-model 进行冗长的等价展开。因此上面的代码其实等价于下面这段:

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

而当使用在一个组件上时,v-model 会被展开为如下的形式:

1
<CustomInput :modelValue="searchText" @update:modelValue="newValue => (searchText = newValue)" />

所以,<CustomInput> 组件内部需要做两件事:

  1. 将内部原生 <input> 元素的 value attribute 绑定到 modelValue prop
  2. 当原生的 input 事件触发时,触发一个携带了新值的 update:modelValue 自定义事件
1
2
3
4
5
6
7
8
9
<!-- CustomInput.vue -->
<script setup>
defineProps(["modelValue"])
defineEmits(["update:modelValue"])
</script>

<template>
<input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>

现在 v-model 可以在这个组件上正常工作了:

1
<CustomInput v-model="searchText" />

另一种在组件内实现 v-model 的方式是使用一个可写的,同时具有 getter 和 setter 的 computed 属性。get 方法需返回 modelValue prop,而 set 方法需触发相应的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- CustomInput.vue -->
<script setup>
import { computed } from "vue"

const props = defineProps(["modelValue"])
const emit = defineEmits(["update:modelValue"])

const value = computed({
get() {
return props.modelValue
},
set(value) {
emit("update:modelValue", value)
},
})
</script>

<template>
<input v-model="value" />
</template>

v-model 的参数

默认情况下,v-model 在组件上都是使用 modelValue 作为 prop,并以 update:modelValue 作为对应的事件。我们可以通过给 v-model 指定一个参数来更改这些名字:

1
<MyComponent v-model:title="bookTitle" />

在这个例子中,子组件应声明一个 title prop,并通过触发 update:title 事件更新父组件值:

1
2
3
4
5
6
7
8
9
<!-- MyComponent.vue -->
<script setup>
defineProps(["title"])
defineEmits(["update:title"])
</script>

<template>
<input type="text" :value="title" @input="$emit('update:title', $event.target.value)" />
</template>

多个v-model绑定

1
<UserName v-model:first-name="first" v-model:last-name="last" />
1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup>
defineProps({
firstName: String,
lastName: String,
})

defineEmits(["update:firstName", "update:lastName"])
</script>

<template>
<input type="text" :value="firstName" @input="$emit('update:firstName', $event.target.value)" />
<input type="text" :value="lastName" @input="$emit('update:lastName', $event.target.value)" />
</template>

处理v-model修饰符

在学习输入绑定时,我们知道了 v-model 有一些内置的修饰符,例如 .trim.number.lazy。在某些场景下,你可能想要一个自定义组件的 v-model 支持自定义的修饰符。

我们来创建一个自定义的修饰符 capitalize,它会自动将 v-model 绑定输入的字符串值第一个字母转为大写:

1
<MyComponent v-model.capitalize="myText" />

组件的 v-model 上所添加的修饰符,可以通过 modelModifiers prop 在组件内访问到。在下面的组件中,我们声明了 modelModifiers 这个 prop,它的默认值是一个空对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) },
})

defineEmits(["update:modelValue"])

console.log(props.modelModifiers) // { capitalize: true }
</script>

<template>
<input type="text" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>

注意这里组件的 modelModifiers prop 包含了 capitalize 且其值为 true,因为它在模板中的 v-model 绑定 v-model.capitalize="myText" 上被使用了。

有了这个 prop,我们就可以检查 modelModifiers 对象的键,并编写一个处理函数来改变抛出的值。在下面的代码里,我们就是在每次 <input /> 元素触发 input 事件时将值的首字母大写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) },
})

const emit = defineEmits(["update:modelValue"])

function emitValue(e) {
let value = e.target.value
if (props.modelModifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
emit("update:modelValue", value)
}
</script>

<template>
<input type="text" :value="modelValue" @input="emitValue" />
</template>

对于又有参数又有修饰符的 v-model 绑定,生成的 prop 名将是 arg + "Modifiers"。举例来说:

1
<MyComponent v-model:title.capitalize="myText">

相应的声明应该是:

1
2
3
4
const props = defineProps(["title", "titleModifiers"])
defineEmits(["update:title"])

console.log(props.titleModifiers) // { capitalize: true }

透传 attributes

attributes 继承

“透传 attribute”指的是传递给一个组件,却没有被该组件声明为 propsemits 的 attribute 或者 v-on 事件监听器。最常见的例子就是 classstyleid

当一个组件以单个元素为根作渲染时,透传的 attribute 会自动被添加到根元素上。举例来说,假如我们有一个 <MyButton> 组件,它的模板长这样:

1
2
<!-- <MyButton> 的模板 -->
<button>click me</button>

一个父组件使用了这个组件,并且传入了 class

1
<MyButton class="large" />

最后渲染出的 DOM 结果是:

1
<button class="large">click me</button>

这里,<MyButton> 并没有将 class 声明为一个它所接受的 prop,所以 class 被视作透传 attribute,自动透传到了 <MyButton> 的根元素上。

classstyle 的合并

如果一个子组件的根元素已经有了 classstyle attribute,它会和从父组件上继承的值合并。如果我们将之前的 <MyButton> 组件的模板改成这样:

1
2
<!-- <MyButton> 的模板 -->
<button class="btn">click me</button>

则最后渲染出的 DOM 结果会变成:

1
<button class="btn large">click me</button>
v-on 监听器继承

同样的规则也适用于 v-on 事件监听器:

1
<MyButton @click="onClick" />

click 监听器会被添加到 <MyButton> 的根元素,即那个原生的 <button> 元素之上。当原生的 <button> 被点击,会触发父组件的 onClick 方法。同样的,如果原生 button 元素自身也通过 v-on 绑定了一个事件监听器,则这个监听器和从父组件继承的监听器都会被触发。

深层组件继承

有些情况下一个组件会在根节点上渲染另一个组件。例如,我们重构一下 <MyButton>,让它在根节点上渲染 <BaseButton>

1
2
<!-- <MyButton/> 的模板,只是渲染另一个组件 -->
<BaseButton />

此时 <MyButton> 接收的透传 attribute 会直接继续传给 <BaseButton>

请注意:

  1. 透传的 attribute 不会包含 <MyButton> 上声明过的 props 或是针对 emits 声明事件的 v-on 侦听函数,换句话说,声明过的 props 和侦听函数被 <MyButton>“消费”了
  2. 透传的 attribute 若符合声明,也可以作为 props 传入 <BaseButton>

禁用 attributes 继承

如果你不想要一个组件自动地继承 attribute,你可以在组件选项中设置 inheritAttrs: false

如果你使用了 <script setup>,你需要一个额外的 <script> 块来书写这个选项声明:

1
2
3
4
5
6
7
8
9
10
<script>
// 使用普通的 <script> 来声明选项
export default {
inheritAttrs: false,
}
</script>

<script setup>
// ...setup 部分逻辑
</script>

最常见的需要禁用 attribute 继承的场景就是 attribute 需要应用在根节点以外的其他元素上。通过设置 inheritAttrs 选项为 false,你可以完全控制透传进来的 attribute 被如何使用。

这些透传进来的 attribute 可以在模板的表达式中直接用 $attrs 访问到。

1
<span>Fallthrough attribute: {{ $attrs }}</span>

这个 $attrs 对象包含了除组件所声明的 propsemits 之外的所有其他 attribute,例如 classstylev-on 监听器等等。

有几点需要注意:

  • 和 props 有所不同,透传 attributes 在 JavaScript 中保留了它们原始的大小写,所以像 foo-bar 这样的一个 attribute 需要通过 $attrs['foo-bar'] 来访问。
  • @click 这样的一个 v-on 事件监听器将在此对象下被暴露为一个函数 $attrs.onClick
1
2
3
<div class="btn-wrapper">
<button class="btn" v-bind="$attrs">click me</button>
</div>

小提示:没有参数的 v-bind 会将一个对象的所有属性都作为 attribute 应用到目标元素上。

多根节点的 attributes 继承

和单根节点组件有所不同,有着多个根节点的组件没有自动 attribute 透传行为。如果 $attrs 没有被显式绑定,将会抛出一个运行时警告。

1
<CustomLayout id="custom-layout" @click="changeValue" />

如果 <CustomLayout> 有下面这样的多根节点模板,由于 Vue 不知道要将 attribute 透传到哪里,所以会抛出一个警告。

1
2
3
<header>...</header>
<main>...</main>
<footer>...</footer>

如果 $attrs 被显式绑定,则不会有警告:

1
2
3
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>

在 JavaScript 中访问透传 Attributes

如果需要,你可以在 <script setup> 中使用 useAttrs() API 来访问一个组件的所有透传 attribute:

1
2
3
4
5
<script setup>
import { useAttrs } from "vue"

const attrs = useAttrs()
</script>

需要注意的是,虽然这里的 attrs 对象总是反映为最新的透传 attribute,但它并不是响应式的 (考虑到性能因素)。你不能通过侦听器去监听它的变化。如果你需要响应性,可以使用 prop。或者你也可以使用声明周期函数 onUpdated() 使得在每次更新时结合最新的 attrs 执行副作用。

插槽 slots

依靠 props 传值,还是不够,如果要传递模板内容,则需要使用插槽 slots。

1
2
3
<FancyButton>
Click me! <!-- 插槽内容 -->
</FancyButton>

<FancyButton> 模板是这样的:

1
2
3
<button class="fancy-btn">
<slot></slot> <!-- 插槽出口 -->
</button>

<slot> 元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。

最终渲染出的 DOM 是这样:

1
<button class="fancy-btn">Click me!</button>

多个插槽

如果有多个插槽,需要给插槽命名。

1
2
3
4
5
6
7
8
9
10
11
<div class="container">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>

这类带 name 的插槽被称为具名插槽 (named slots)。没有提供 name<slot> 出口会隐式地命名为“default”。

要为具名插槽传入内容,我们需要使用一个含 v-slot 指令的 <template> 元素,并将目标插槽的名字传给该指令,v-slot 有对应的简写 #

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<BaseLayout>
<template #header>
<h1>Here might be a page title</h1>
</template>

<template #default>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
</template>

<template #footer>
<p>Here's some contact info</p>
</template>
</BaseLayout>

当一个组件同时接收默认插槽和具名插槽时,所有位于顶级的非 <template> 节点都被隐式地视为默认插槽的内容。所以上面也可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
<BaseLayout>
<template #header>
<h1>Here might be a page title</h1>
</template>

<!-- 隐式的默认插槽 -->
<p>A paragraph for the main content.</p>
<p>And another one.</p>

<template #footer>
<p>Here's some contact info</p>
</template>
</BaseLayout>

向插槽传参

Vue 模板中的表达式只能访问其定义时所处的作用域,这和 JavaScript 的词法作用域规则是一致的。

子组件定义插槽,父组件中使用子组件的时候,定义插槽中的内容。

插槽中内容是在父组件中定义的,所以插槽中只能访问父组件的作用域,但是如果插槽中需要使用到子组件作用域中的数据,怎么办?

子组件在定义插槽的时候,将需要使用到的数据传入插槽,这样,父组件在定义插槽中的内容时,就能使用传入的数据了。

定义组件 FacyList,并将item传入:

1
2
3
4
5
<ul>
<li v-for="item in items">
<slot name="item" :body="item.body" :username="item.username" :likes="item.links"></slot>
</li>
</ul>

或者:

1
2
3
4
5
<ul>
<li v-for="item in items">
<slot name="item" v-bind="item"></slot>
</li>
</ul>

注意:插槽上的 name 是一个 Vue 特别保留的 attribute,不会作为 props 传递给插槽。

在父组件中引用子组件 FancyList,可以使用传入的参数:

1
2
3
4
5
6
7
8
<FancyList>
<template #item="{ body, username, likes }">
<div class="item">
<p>{{ body }}</p>
<p>by {{ username }} | {{ likes }} likes</p>
</div>
</template>
</FancyList>

无渲染组件

一些组件可能只包括了逻辑而不需要自己渲染内容,视图输出通过作用域插槽全权交给了消费者组件。我们将这种类型的组件称为无渲染组件

大部分能用无渲染组件实现的功能都可以通过组合式 API 以另一种更高效的方式实现,并且还不会带来额外组件嵌套的开销。

依赖注入

深层的组件需要顶层的数据,如果通过层层组件逐级传递 props,会很麻烦,中间层的组件可能根本不关系这些 props。

这个问题被称为 “prop 逐级透传”。

provide(提供) 和 inject(注入) 可以帮助我们解决这一问题。一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。

provide(提供)

要为组件后代提供数据,需要使用到 provide() 函数:

1
2
3
4
5
<script setup>
import { provide } from "vue"

provide(/* 注入名 */ "message", /* 值 */ "hello!")
</script>

注入名 可以是字符串或是 Symbol

后代组件会用注入名来查找期望注入的值。

可以多次调用 provide(),使用不同的注入名,注入不同的依赖值。

可以是任意类型,包括响应式的状态,比如一个 ref:

1
2
3
4
import { ref, provide } from "vue"

const count = ref(0)
provide("key", count)

提供的响应式状态使后代组件可以由此和提供者建立响应式的联系。

应用层 provide

除了在一个组件中提供依赖,我们还可以在整个应用层面提供依赖:

1
2
3
4
5
import { createApp } from "vue"

const app = createApp({})

app.provide(/* 注入名 */ "message", /* 值 */ "hello!")

在应用级别提供的数据在该应用内的所有组件中都可以注入。这在你编写插件时会特别有用,因为插件一般都不会使用组件形式来提供值。

inject(注入)

要注入上层组件提供的数据,需使用 inject() 函数:

1
2
3
4
5
<script setup>
import { inject } from "vue"

const message = inject("message")
</script>

如果提供的值是一个 ref,注入进来的会是该 ref 对象,而不会自动解包为其内部的值。这使得注入方组件能够通过 ref 对象保持了和供给方的响应性链接。

注入默认值
1
2
3
// 如果没有祖先组件提供 "message"
// `value` 会是 "这是默认值"
const value = inject("message", "这是默认值")

或者:

s
1
const value = inject('key', () => new ExpensiveClass())

和响应式数据配合使用

当提供 / 注入响应式的数据时,建议尽可能将任何对响应式状态的变更都保持在供给方组件中。这样可以确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护。

有的时候,我们可能需要在注入方组件中更改数据。在这种情况下,我们推荐在供给方组件内声明并提供一个更改数据的方法函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 在供给方组件内 -->
<script setup>
import { provide, ref } from "vue"

const location = ref("North Pole")

function updateLocation() {
location.value = "South Pole"
}

provide("location", {
location,
updateLocation,
})
</script>
1
2
3
4
5
6
7
8
9
10
<!-- 在注入方组件 -->
<script setup>
import { inject } from "vue"

const { location, updateLocation } = inject("location")
</script>

<template>
<button @click="updateLocation">{{ location }}</button>
</template>

最后,如果你想确保提供的数据不能被注入方的组件更改,你可以使用 readonly() 来包装提供的值。

1
2
3
4
5
6
<script setup>
import { ref, provide, readonly } from "vue"

const count = ref(0)
provide("read-only-count", readonly(count))
</script>

使用 symbol 作为注入名

如果你正在构建大型的应用,包含非常多的依赖提供,或者你正在编写提供给其他开发者使用的组件库,建议最好使用 Symbol 来作为注入名以避免潜在的冲突。

我们通常推荐在一个单独的文件中导出这些注入名 Symbol:

1
2
// keys.js
export const myInjectionKey = Symbol()
1
2
3
4
5
6
7
8
9
// 在供给方组件中
import { provide } from "vue"
import { myInjectionKey } from "./keys.js"

provide(myInjectionKey, {
/*
要提供的数据
*/
})
1
2
3
4
5
// 注入方组件
import { inject } from "vue"
import { myInjectionKey } from "./keys.js"

const injected = inject(myInjectionKey)

异步组件

基本用法

在大型项目中,我们可能需要拆分应用为更小的块,并仅在需要时再从服务器加载相关组件。Vue 提供了 defineAsyncComponent 方法来实现此功能:

1
2
3
4
5
6
7
8
9
import { defineAsyncComponent } from "vue"

const AsyncComp = defineAsyncComponent(() => {
return new Promise((resolve, reject) => {
// ...从服务器获取组件
resolve(/* 获取到的组件 */)
})
})
// ... 像使用其他一般组件一样使用 `AsyncComp`

如你所见,defineAsyncComponent 方法接收一个返回 Promise 的加载函数。这个 Promise 的 resolve 回调方法应该在从服务器获得组件定义时调用。你也可以调用 reject(reason) 表明加载失败。

ES 模块动态导入也会返回一个 Promise,所以多数情况下我们会将它和 defineAsyncComponent 搭配使用。类似 Vite 和 Webpack 这样的构建工具也支持此语法 (并且会将它们作为打包时的代码分割点),因此我们也可以用它来导入 Vue 单文件组件:

1
2
3
import { defineAsyncComponent } from "vue"

const AsyncComp = defineAsyncComponent(() => import("./components/MyComponent.vue"))

最后得到的 AsyncComp 是一个外层包装过的组件,仅在页面需要它渲染时才会调用加载内部实际组件的函数。它会将接收到的 props 和插槽传给内部组件,所以你可以使用这个异步的包装组件无缝地替换原始组件,同时实现延迟加载。

加载与错误状态

异步操作不可避免地会涉及到加载和错误状态,因此 defineAsyncComponent() 也支持在高级选项中处理这些状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const AsyncComp = defineAsyncComponent({
// 加载函数
loader: () => import("./Foo.vue"),

// 加载异步组件时使用的组件
loadingComponent: LoadingComponent,
// 展示加载组件前的延迟时间,默认为 200ms
delay: 200,

// 加载失败后展示的组件
errorComponent: ErrorComponent,
// 如果提供了一个 timeout 时间限制,并超时了
// 也会显示这里配置的报错组件,默认值是:Infinity
timeout: 3000,
})

如果提供了一个加载组件,它将在内部组件加载时先行显示。在加载组件显示之前有一个默认的 200ms 延迟——这是因为在网络状况较好时,加载完成得很快,加载组件和最终组件之间的替换太快可能产生闪烁,反而影响用户感受。

如果提供了一个报错组件,则它会在加载器函数返回的 Promise 抛错时被渲染。你还可以指定一个超时时间,在请求耗时超过指定时间时也会渲染报错组件。

搭配 Suspense 使用

异步组件可以搭配内置的 <Suspense> 组件一起使用,若想了解 <Suspense> 和异步组件之间交互,请参阅 Suspense 章节。

逻辑复用

组合式函数

复用无状态逻辑的库有很多,比如你可能已经用过的 lodash 或是 date-fns

在 Vue 中,复用有状态逻辑使用“组合式函数”(Composables) 。

和组件一样,可以在组合式函数中使用所有的 组合式 API,并返回需要暴露的状态。

更酷的是,你还可以嵌套多个组合式函数:一个组合式函数可以调用一个或多个其他的组合式函数。这使得我们可以像使用多个组件组合成整个应用一样,用多个较小且逻辑独立的单元来组合形成复杂的逻辑。实际上,这正是为什么我们决定将实现了这一设计模式的 API 集合命名为组合式 API。

异步状态示例示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// fetch.js
import { ref } from "vue"

export const useFetch = (url: string) => {
const data = ref(null)
const loading = ref(true)
const error = ref(null)
const request = () =>
window
.fetch(url)
.then(res => res.json())
.then(json => (data.value = json))
.catch(err => (error.value = err))
.finally(() => (loading.value = false))

request()

return {
data,
loading,
error,
refetch: request,
}
}
1
2
3
4
5
6
7
8
9
10
<script lang="ts" setup>
import { useFetch } from "@/utils/fetch"

const { data, loading, error, refetch } = useFetch("http://101.43.187.22:9501/api/nav/wallPaper")
</script>

<template>
<el-button @click="refetch">refresh</el-button>
<el-row v-for="url of data?.result" :key="url" v-loading="loading">{{ url }}</el-row>
</template>

推荐使用 TanStack Query 库。

约定和最佳实践

命名

组合式函数约定用驼峰命名法命名,并以“use”作为开头。

输入参数
返回值

你可能已经注意到了,我们一直在组合式函数中使用 ref() 而不是 reactive()。我们推荐的约定是组合式函数始终返回一个包含多个 ref 的普通的非响应式对象,这样该对象在组件中被解构为 ref 之后仍可以保持响应性:

1
2
// x 和 y 是两个 ref
const { x, y } = useMouse()

从组合式函数返回一个响应式对象会导致在对象解构过程中丢失与组合式函数内状态的响应性连接。与之相反,ref 则可以维持这一响应性连接。

如果你更希望以对象属性的形式来使用组合式函数中返回的状态,你可以将返回的对象用 reactive() 包装一次,这样其中的 ref 会被自动解包,例如:

1
2
3
const mouse = reactive(useMouse())
// mouse.x 链接到了原来的 x ref
console.log(mouse.x)
1
Mouse position is at: {{ mouse.x }}, {{ mouse.y }}
副作用

在组合式函数中的确可以执行副作用 (例如:添加 DOM 事件监听器或者请求数据),但请注意以下规则:

  • 如果你的应用用到了服务端渲染 (SSR),请确保在组件挂载后才调用的生命周期钩子中执行 DOM 相关的副作用,例如:onMounted()。这些钩子仅会在浏览器中被调用,因此可以确保能访问到 DOM。

  • 确保在 onUnmounted() 时清理副作用。举例来说,如果一个组合式函数设置了一个事件监听器,它就应该在 onUnmounted() 中被移除。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // event.js
    import { onMounted, onUnmounted } from "vue"

    export function useEventListener(target, event, callback) {
    // 如果你想的话,
    // 也可以用字符串形式的 CSS 选择器来寻找目标 DOM 元素
    onMounted(() => target.addEventListener(event, callback))
    onUnmounted(() => target.removeEventListener(event, callback)) // 清理
    }
使用限制

组合式函数在 <script setup>setup() 钩子中,应始终被同步地调用。在某些场景下,你也可以在像 onMounted() 这样的生命周期钩子中使用他们。

这个限制是为了让 Vue 能够确定当前正在被执行的到底是哪个组件实例,只有能确认当前组件实例,才能够:

  1. 将生命周期钩子注册到该组件实例上;
  2. 将计算属性和监听器注册到该组件实例上,以便在该组件被卸载时停止监听,避免内存泄漏。

提示:

<script setup> 是唯一在调用 await 之后仍可调用组合式函数的地方。编译器会在异步操作之后自动为你恢复当前的组件实例。

自定义指令

除了 Vue 内置的一系列指令 (比如 v-modelv-show) 之外,Vue 还允许你注册自定义的指令 (Custom Directives)。

我们已经介绍了两种在 Vue 中重用代码的方式:组件组合式函数。组件是主要的构建模块,而组合式函数则侧重于有状态的逻辑。另一方面,自定义指令主要是为了重用涉及普通元素的底层 DOM 访问的逻辑。

一个自定义指令由一个包含类似组件生命周期钩子的对象来定义。钩子函数会接收到指令所绑定元素作为其参数。下面是一个自定义指令的例子,当一个 input 元素被 Vue 插入到 DOM 中后,它会被自动聚焦:

1
2
3
4
5
6
7
8
9
10
<script setup>
// 在模板中启用 v-focus
const vFocus = {
mounted: el => el.focus(),
}
</script>

<template>
<input v-focus />
</template>

假设你还未点击页面中的其他地方,那么上面这个 input 元素应该会被自动聚焦。该指令比 autofocus attribute 更有用,因为它不仅仅可以在页面加载完成后生效,还可以在 Vue 动态插入元素后生效。

<script setup> 中,任何以 v 开头的驼峰式命名的变量都可以被用作一个自定义指令。在上面的例子中,vFocus 即可以在模板中以 v-focus 的形式使用。

将一个自定义指令全局注册到应用层级也是一种常见的做法:

1
2
3
4
5
6
const app = createApp({})

// 使 v-focus 在所有组件中都可用
app.directive("focus", {
/* ... */
})

提示:

只有当所需功能只能通过直接的 DOM 操作来实现时,才应该使用自定义指令。其他情况下应该尽可能地使用 v-bind 这样的内置指令来声明式地使用模板,这样更高效,也对服务端渲染更友好。

指令钩子

一个指令的定义对象可以提供几种钩子函数 (都是可选的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const myDirective = {
// 在绑定元素的 attribute 前
// 或事件监听器应用前调用
created(el, binding, vnode, prevVnode) {
// 下面会介绍各个参数的细节
},
// 在元素被插入到 DOM 前调用
beforeMount(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
mounted(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件更新前调用
beforeUpdate(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件
// 及他自己的所有子节点都更新后调用
updated(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载前调用
beforeUnmount(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载后调用
unmounted(el, binding, vnode, prevVnode) {},
}

钩子参数

简化形式

对象字面量

在组件上使用

当在组件上使用自定义指令时,它会始终应用于组件的根节点,和透传 attributes 类似。

总的来说,推荐在组件上使用自定义指令。

插件

插件 (Plugins) 是一种能为 Vue 添加全局功能的工具代码。下面是如何安装一个插件的示例:

1
2
3
4
5
6
7
import { createApp } from "vue"

const app = createApp({})

app.use(myPlugin, {
/* 可选的选项 */
})

一个插件可以是一个拥有 install() 方法的对象,也可以直接是一个安装函数本身。安装函数会接收到安装它的应用实例和传递给 app.use() 的额外选项作为参数:

1
2
3
4
5
const myPlugin = {
install(app, options) {
// 配置此应用
},
}

插件没有严格定义的使用范围,但是插件发挥作用的常见场景主要包括以下几种:

  1. 通过 app.component()app.directive() 注册一到多个全局组件或自定义指令。
  2. 通过 app.provide() 使一个资源 可被注入 进整个应用。
  3. app.config.globalProperties 中添加一些全局实例属性或方法
  4. 一个可能上述三种都包含了的功能库 (例如 vue-router)。

编写一个插件

内置组件

Transition

在一个元素或组件进入和离开 DOM 时应用动画。

TransitionGroup

在一个 v-for 列表中的元素或组件被插入,移动,或移除时应用动画。

KeepAlive

想要组件能在被“切走”的时候保留它们的状态。

可以用 <KeepAlive> 内置组件将这些动态组件包装起来。

Teleport

它可以将一个组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去。

Suspense

<Suspense> 是一项实验性功能。它不一定会最终成为稳定功能,并且在稳定之前相关 API 也可能会发生变化。

<Suspense> 是一个内置组件,用来在组件树中协调对异步依赖的处理。它让我们可以在组件树上层等待下层的多个嵌套异步依赖项解析完成,并可以在等待时渲染一个加载状态。

应用规模化

状态管理 pinia

测试

服务端渲染 (SSR)

最佳实践

TypeScript

进阶主题

转载:

https://gitee.com/tgzhome/tencent-code-security-guide/blob/main/Go%E5%AE%89%E5%85%A8%E6%8C%87%E5%8D%97.md

通用类

1. 代码实现类

1.1 内存管理

1.1.1【必须】切片长度校验

  • 在对slice进行操作时,必须判断长度是否合法,防止程序panic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// bad: 未判断data的长度,可导致 index out of range
func decode(data []byte) bool {
if data[0] == 'F' && data[1] == 'U' && data[2] == 'Z' && data[3] == 'Z' && data[4] == 'E' && data[5] == 'R' {
fmt.Println("Bad")
return true
}
return false
}

// bad: slice bounds out of range
func foo() {
var slice = []int{0, 1, 2, 3, 4, 5, 6}
fmt.Println(slice[:10])
}

// good: 使用data前应判断长度是否合法
func decode(data []byte) bool {
if len(data) == 6 {
if data[0] == 'F' && data[1] == 'U' && data[2] == 'Z' && data[3] == 'Z' && data[4] == 'E' && data[5] == 'R' {
fmt.Println("Good")
return true
}
}
return false
}

1.1.2【必须】nil指针判断

  • 进行指针操作时,必须判断该指针是否为nil,防止程序panic,尤其在进行结构体Unmarshal时
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
type Packet struct {
PackeyType uint8
PackeyVersion uint8
Data *Data
}

type Data struct {
Stat uint8
Len uint8
Buf [8]byte
}

func (p *Packet) UnmarshalBinary(b []byte) error {
if len(b) < 2 {
return io.EOF
}

p.PackeyType = b[0]
p.PackeyVersion = b[1]

// 若长度等于2,那么不会new Data
if len(b) > 2 {
p.Data = new(Data)
}
return nil
}

// bad: 未判断指针是否为nil
func main() {
packet := new(Packet)
data := make([]byte, 2)
if err := packet.UnmarshalBinary(data); err != nil {
fmt.Println("Failed to unmarshal packet")
return
}

fmt.Printf("Stat: %v\n", packet.Data.Stat)
}

// good: 判断Data指针是否为nil
func main() {
packet := new(Packet)
data := make([]byte, 2)

if err := packet.UnmarshalBinary(data); err != nil {
fmt.Println("Failed to unmarshal packet")
return
}

if packet.Data == nil {
return
}

fmt.Printf("Stat: %v\n", packet.Data.Stat)
}

1.1.3【必须】整数安全

  • 在进行数字运算操作时,需要做好长度限制,防止外部输入运算导致异常:

    • 确保无符号整数运算时不会反转
    • 确保有符号整数运算时不会出现溢出
    • 确保整型转换时不会出现截断错误
    • 确保整型转换时不会出现符号错误
  • 以下场景必须严格进行长度限制:

    • 作为数组索引
    • 作为对象的长度或者大小
    • 作为数组的边界(如作为循环计数器)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// bad: 未限制长度,导致整数溢出
func overflow(numControlByUser int32) {
var numInt int32 = 0
numInt = numControlByUser + 1
// 对长度限制不当,导致整数溢出
fmt.Printf("%d\n", numInt)
// 使用numInt,可能导致其他错误
}

func main() {
overflow(2147483647)
}

// good
func overflow(numControlByUser int32) {
var numInt int32 = 0
numInt = numControlByUser + 1
if numInt < 0 {
fmt.Println("integer overflow")
return
}
fmt.Println("integer ok")
}

func main() {
overflow(2147483647)
}

1.1.4【必须】make分配长度验证

  • 在进行make分配内存时,需要对外部可控的长度进行校验,防止程序panic。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// bad
func parse(lenControlByUser int, data []byte) {
size := lenControlByUser
// 对外部传入的size,进行长度判断以免导致panic
buffer := make([]byte, size)
copy(buffer, data)
}

// good
func parse(lenControlByUser int, data []byte) ([]byte, error) {
size := lenControlByUser
// 限制外部可控的长度大小范围
if size > 64*1024*1024 {
return nil, errors.New("value too large")
}
buffer := make([]byte, size)
copy(buffer, data)
return buffer, nil
}

1.1.5【必须】禁止SetFinalizer和指针循环引用同时使用

  • 当一个对象从被GC选中到移除内存之前,runtime.SetFinalizer()都不会执行,即使程序正常结束或者发生错误。由指针构成的“循环引用”虽然能被GC正确处理,但由于无法确定Finalizer依赖顺序,从而无法调用runtime.SetFinalizer(),导致目标对象无法变成可达状态,从而造成内存无法被回收。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// bad
func foo() {
var a, b Data
a.o = &b
b.o = &a

// 指针循环引用,SetFinalizer()无法正常调用
runtime.SetFinalizer(&a, func(d *Data) {
fmt.Printf("a %p final.\n", d)
})
runtime.SetFinalizer(&b, func(d *Data) {
fmt.Printf("b %p final.\n", d)
})
}

func main() {
for {
foo()
time.Sleep(time.Millisecond)
}
}

1.1.6【必须】禁止重复释放channel

  • 重复释放一般存在于异常流程判断中,如果恶意攻击者构造出异常条件使程序重复释放channel,则会触发运行时panic,从而造成DoS攻击。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// bad
func foo(c chan int) {
defer close(c)
err := processBusiness()
if err != nil {
c <- 0
close(c) // 重复释放channel
return
}
c <- 1
}

// good
func foo(c chan int) {
defer close(c) // 使用defer延迟关闭channel
err := processBusiness()
if err != nil {
c <- 0
return
}
c <- 1
}

1.1.7【必须】确保每个协程都能退出

  • 启动一个协程就会做一个入栈操作,在系统不退出的情况下,协程也没有设置退出条件,则相当于协程失去了控制,它占用的资源无法回收,可能会导致内存泄露。
1
2
3
4
5
6
7
// bad: 协程没有设置退出条件
func doWaiter(name string, second int) {
for {
time.Sleep(time.Duration(second) * time.Second)
fmt.Println(name, " is ready!")
}
}

1.1.8【推荐】不使用unsafe包

  • 由于unsafe包绕过了 Golang 的内存安全原则,一般来说使用该库是不安全的,可导致内存破坏,尽量避免使用该包。若必须要使用unsafe操作指针,必须做好安全校验。
1
2
3
4
5
6
7
8
9
// bad: 通过unsafe操作原始指针
func unsafePointer() {
b := make([]byte, 1)
foo := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(0xfffffffe)))
fmt.Print(*foo + 1)
}

// [signal SIGSEGV: segmentation violation code=0x1 addr=0xc100068f55 pc=0x49142b]

1.1.9【推荐】不使用slice作为函数入参

  • slice在作为函数入参时,函数内对slice的修改可能会影响原始数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// bad
// slice作为函数入参时包含原始数组指针
func modify(array []int) {
array[0] = 10 // 对入参slice的元素修改会影响原始数据
}

func main() {
array := []int{1, 2, 3, 4, 5}

modify(array)
fmt.Println(array) // output:[10 2 3 4 5]
}

// good
// 数组作为函数入参,而不是slice
func modify(array [5]int) {
array[0] = 10
}

func main() {
// 传入数组,注意数组与slice的区别
array := [5]int{1, 2, 3, 4, 5}

modify(array)
fmt.Println(array)
}

1.2 文件操作

1.2.1【必须】 路径穿越检查

  • 在进行文件操作时,如果对外部传入的文件名未做限制,可能导致任意文件读取或者任意文件写入,严重可能导致代码执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// bad: 任意文件读取
func handler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Query()["path"][0]

// 未过滤文件路径,可能导致任意文件读取
data, _ := ioutil.ReadFile(path)
w.Write(data)

// 对外部传入的文件名变量,还需要验证是否存在../等路径穿越的文件名
data, _ = ioutil.ReadFile(filepath.Join("/home/user/", path))
w.Write(data)
}

// bad: 任意文件写入
func unzip(f string) {
r, _ := zip.OpenReader(f)
for _, f := range r.File {
p, _ := filepath.Abs(f.Name)
// 未验证压缩文件名,可能导致../等路径穿越,任意文件路径写入
ioutil.WriteFile(p, []byte("present"), 0640)
}
}

// good: 检查压缩的文件名是否包含..路径穿越特征字符,防止任意写入
func unzipGood(f string) bool {
r, err := zip.OpenReader(f)
if err != nil {
fmt.Println("read zip file fail")
return false
}
for _, f := range r.File {
if !strings.Contains(f.Name, "..") {
p, _ := filepath.Abs(f.Name)
ioutil.WriteFile(p, []byte("present"), 0640)
} else {
return false
}
}
return true
}

1.2.2【必须】 文件访问权限

  • 根据创建文件的敏感性设置不同级别的访问权限,以防止敏感数据被任意权限用户读取。例如,设置文件权限为:-rw-r—–
1
ioutil.WriteFile(p, []byte("present"), 0640)

1.3 系统接口

1.3.1【必须】命令执行检查

  • 使用exec.Command、exec.CommandContext、syscall.StartProcess、os.StartProcess等函数时,第一个参数(path)直接取外部输入值时,应使用白名单限定可执行的命令范围,不允许传入bash、cmd、sh等命令;
  • 使用exec.Command、exec.CommandContext等函数时,通过bash、cmd、sh等创建shell,-c后的参数(arg)拼接外部输入,应过滤\n $ & ; | ‘ “ ( ) `等潜在恶意字符;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// bad
func foo() {
userInputedVal := "&& echo 'hello'" // 假设外部传入该变量值
cmdName := "ping " + userInputedVal

// 未判断外部输入是否存在命令注入字符,结合sh可造成命令注入
cmd := exec.Command("sh", "-c", cmdName)
output, _ := cmd.CombinedOutput()
fmt.Println(string(output))

cmdName := "ls"
// 未判断外部输入是否是预期命令
cmd := exec.Command(cmdName)
output, _ := cmd.CombinedOutput()
fmt.Println(string(output))
}

// good
func checkIllegal(cmdName string) bool {
if strings.Contains(cmdName, "&") || strings.Contains(cmdName, "|") || strings.Contains(cmdName, ";") ||
strings.Contains(cmdName, "$") || strings.Contains(cmdName, "'") || strings.Contains(cmdName, "`") ||
strings.Contains(cmdName, "(") || strings.Contains(cmdName, ")") || strings.Contains(cmdName, "\"") {
return true
}
return false
}

func main() {
userInputedVal := "&& echo 'hello'"
cmdName := "ping " + userInputedVal

if checkIllegal(cmdName) { // 检查传给sh的命令是否有特殊字符
return // 存在特殊字符直接return
}

cmd := exec.Command("sh", "-c", cmdName)
output, _ := cmd.CombinedOutput()
fmt.Println(string(output))
}

1.4 通信安全

1.4.1【必须】网络通信采用TLS方式

  • 明文传输的通信协议目前已被验证存在较大安全风险,被中间人劫持后可能导致许多安全风险,因此必须采用至少TLS的安全通信方式保证通信安全,例如gRPC/Websocket都使用TLS1.3。
1
2
3
4
5
6
7
8
9
10
// good
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
w.Header().Add("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
w.Write([]byte("This is an example server.\n"))
})

// 服务器配置证书与私钥
log.Fatal(http.ListenAndServeTLS(":443", "yourCert.pem", "yourKey.pem", nil))
}

1.4.2【推荐】TLS启用证书验证

  • TLS证书应当是有效的、未过期的,且配置正确的域名,生产环境的服务端应启用证书验证。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// bad
import (
"crypto/tls"
"net/http"
)

func doAuthReq(authReq *http.Request) *http.Response {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
res, _ := client.Do(authReq)
return res
}

// good
import (
"crypto/tls"
"net/http"
)

func doAuthReq(authReq *http.Request) *http.Response {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
}
client := &http.Client{Transport: tr}
res, _ := client.Do(authReq)
return res
}

1.5 敏感数据保护

1.5.1【必须】敏感信息访问

  • 禁止将敏感信息硬编码在程序中,既可能会将敏感信息暴露给攻击者,也会增加代码管理和维护的难度
  • 使用配置中心系统统一托管密钥等敏感信息

1.5.2【必须】敏感数据输出

  • 只输出必要的最小数据集,避免多余字段暴露引起敏感信息泄露
  • 不能在日志保存密码(包括明文密码和密文密码)、密钥和其它敏感信息
  • 对于必须输出的敏感信息,必须进行合理脱敏展示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// bad
func serve() {
http.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
user := r.Form.Get("user")
pw := r.Form.Get("password")

log.Printf("Registering new user %s with password %s.\n", user, pw)
})
http.ListenAndServe(":80", nil)
}

// good
func serve1() {
http.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
user := r.Form.Get("user")
pw := r.Form.Get("password")

log.Printf("Registering new user %s.\n", user)

// ...
use(pw)
})
http.ListenAndServe(":80", nil)
}
  • 避免通过GET方法、代码注释、自动填充、缓存等方式泄露敏感信息

1.5.3【必须】敏感数据存储

  • 敏感数据应使用SHA2、RSA等算法进行加密存储
  • 敏感数据应使用独立的存储层,并在访问层开启访问控制
  • 包含敏感信息的临时文件或缓存一旦不再需要应立刻删除

1.5.4【必须】异常处理和日志记录

  • 应合理使用panic、recover、defer处理系统异常,避免出错信息输出到前端
1
2
3
4
5
defer func () {
if r := recover(); r != nil {
fmt.Println("Recovered in start()")
}
}()
  • 对外环境禁止开启debug模式,或将程序运行日志输出到前端
1
2
3
4
// bad
dlv --listen=:2345 --headless=true --api-version=2 debug test.go
// good
dlv debug test.go

1.6 加密解密

1.6.1【必须】不得硬编码密码/密钥

  • 在进行用户登陆,加解密算法等操作时,不得在代码里硬编码密钥或密码,可通过变换算法或者配置等方式设置密码或者密钥。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// bad
const (
user = "dbuser"
password = "s3cretp4ssword"
)

func connect() *sql.DB {
connStr := fmt.Sprintf("postgres://%s:%s@localhost/pqgotest", user, password)
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil
}
return db
}

// bad
var (
commonkey = []byte("0123456789abcdef")
)

func AesEncrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(commonkey)
if err != nil {
return "", err
}
}

1.6.2【必须】密钥存储安全

  • 在使用对称密码算法时,需要保护好加密密钥。当算法涉及敏感、业务数据时,可通过非对称算法协商加密密钥。其他较为不敏感的数据加密,可以通过变换算法等方式保护密钥。

1.6.3【推荐】不使用弱密码算法

  • 在使用加密算法时,不建议使用加密强度较弱的算法。

1.7 正则表达式

1.7.1【推荐】使用regexp进行正则表达式匹配

  • 正则表达式编写不恰当可被用于DoS攻击,造成服务不可用,推荐使用regexp包进行正则表达式匹配。regexp保证了线性时间性能和优雅的失败:对解析器、编译器和执行引擎都进行了内存限制。但regexp不支持以下正则表达式特性,如业务依赖这些特性,则regexp不适合使用。
    • 回溯引用Backreferences
    • 查看Lookaround
1
2
3
4
// good
matched, err := regexp.MatchString(`a.b`, "aaxbb")
fmt.Println(matched) // true
fmt.Println(err) // nil

后台类

1 代码实现类

1.1 输入校验

1.1.1【必须】按类型进行数据校验

  • 所有外部输入的参数,应使用validator进行白名单校验,校验内容包括但不限于数据长度、数据范围、数据类型与格式,校验不通过的应当拒绝
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// good
import (
"fmt"
"github.com/go-playground/validator/v10"
)

var validate *validator.Validate

func validateVariable() {
myEmail := "abc@tencent.com"
errs := validate.Var(myEmail, "required,email")
if errs != nil {
fmt.Println(errs)
return
//停止执行
}
// 验证通过,继续执行
...
}

func main() {
validate = validator.New()
validateVariable()
}
  • 无法通过白名单校验的应使用html.EscapeString、text/template或bluemonday对<, >, &, ‘,”等字符进行过滤或编码
1
2
3
4
5
6
7
8
9
import (
"text/template"
)

// TestHTMLEscapeString HTML特殊字符转义
func main(inputValue string) string {
escapedResult := template.HTMLEscapeString(inputValue)
return escapedResult
}

1.2 SQL操作

1.2.1【必须】SQL语句默认使用预编译并绑定变量

  • 使用database/sql的prepare、Query或使用GORM等ORM执行SQL操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import (
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/sqlite"
)

type Product struct {
gorm.Model
Code string
Price uint
}

...
var product Product
...
db.First(&product, 1)
  • 使用参数化查询,禁止拼接SQL语句,另外对于传入参数用于order by或表名的需要通过校验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// bad
import (
"database/sql"
"fmt"
"net/http"
)

func handler(db *sql.DB, req *http.Request) {
q := fmt.Sprintf("SELECT ITEM,PRICE FROM PRODUCT WHERE ITEM_CATEGORY='%s' ORDER BY PRICE",
req.URL.Query()["category"])
db.Query(q)
}

// good
func handlerGood(db *sql.DB, req *http.Request) {
// 使用?占位符
q := "SELECT ITEM,PRICE FROM PRODUCT WHERE ITEM_CATEGORY='?' ORDER BY PRICE"
db.Query(q, req.URL.Query()["category"])
}

1.3 网络请求

1.3.1【必须】资源请求过滤验证

  • 使用”net/http”下的方法http.Get(url)、http.Post(url, contentType, body)、http.Head(url)、http.PostForm(url, data)、http.Do(req)时,如变量值外部可控(指从参数中动态获取),应对请求目标进行严格的安全校验。

  • 如请求资源域名归属固定的范围,如只允许a.qq.com和b.qq.com,应做白名单限制。如不适用白名单,则推荐的校验逻辑步骤是:

    • 第 1 步、只允许HTTP或HTTPS协议

    • 第 2 步、解析目标URL,获取其HOST

    • 第 3 步、解析HOST,获取HOST指向的IP地址转换成Long型

    • 第 4 步、检查IP地址是否为内网IP,网段有:

      1
      2
      3
      4
      5
      // 以RFC定义的专有网络为例,如有自定义私有网段亦应加入禁止访问列表。
      10.0.0.0/8
      172.16.0.0/12
      192.168.0.0/16
      127.0.0.0/8
    • 第 5 步、请求URL

    • 第 6 步、如有跳转,跳转后执行1,否则绑定经校验的ip和域名,对URL发起请求

  • 官方库encoding/xml不支持外部实体引用,使用该库可避免xxe漏洞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import (
"encoding/xml"
"fmt"
"os"
)

func main() {
type Person struct {
XMLName xml.Name `xml:"person"`
Id int `xml:"id,attr"`
UserName string `xml:"name>first"`
Comment string `xml:",comment"`
}

v := &Person{Id: 13, UserName: "John"}
v.Comment = " Need more details. "

enc := xml.NewEncoder(os.Stdout)
enc.Indent(" ", " ")
if err := enc.Encode(v); err != nil {
fmt.Printf("error: %v\n", err)
}

}

1.4 服务器端渲染

1.4.1【必须】模板渲染过滤验证

  • 使用text/template或者html/template渲染模板时禁止将外部输入参数引入模板,或仅允许引入白名单内字符。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// bad
func handler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
x := r.Form.Get("name")

var tmpl = `<!DOCTYPE html><html><body>
<form action="/" method="post">
First name:<br>
<input type="text" name="name" value="">
<input type="submit" value="Submit">
</form><p>` + x + ` </p></body></html>`

t := template.New("main")
t, _ = t.Parse(tmpl)
t.Execute(w, "Hello")
}

// good
import (
"fmt"
"github.com/go-playground/validator/v10"
)

var validate *validator.Validate
validate = validator.New()

func validateVariable(val) {
errs := validate.Var(val, "gte=1,lte=100") // 限制必须是1-100的正整数
if errs != nil {
fmt.Println(errs)
return false
}
return true
}

func handler(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
x := r.Form.Get("name")

if validateVariable(x) {
var tmpl = `<!DOCTYPE html><html><body>
<form action="/" method="post">
First name:<br>
<input type="text" name="name" value="">
<input type="submit" value="Submit">
</form><p>` + x + ` </p></body></html>`
t := template.New("main")
t, _ = t.Parse(tmpl)
t.Execute(w, "Hello")
} else {
// ...
}
}

1.5 Web跨域

1.5.1【必须】跨域资源共享CORS限制请求来源

  • CORS请求保护不当可导致敏感信息泄漏,因此应当严格设置Access-Control-Allow-Origin使用同源策略进行保护。
1
2
3
4
5
6
7
8
9
// good
c := cors.New(cors.Options{
AllowedOrigins: []string{"http://qq.com", "https://qq.com"},
AllowCredentials: true,
Debug: false,
})

// 引入中间件
handler = c.Handler(handler)

1.6 响应输出

1.6.1 【必须】设置正确的HTTP响应包类型

  • 响应头Content-Type与实际响应内容,应保持一致。如:API响应数据类型是json,则响应头使用application/json;若为xml,则设置为text/xml。

1.6.2 【必须】添加安全响应头

  • 所有接口、页面,添加响应头 X-Content-Type-Options: nosniff。
  • 所有接口、页面,添加响应头X-Frame-Options。按需合理设置其允许范围,包括:DENY、SAMEORIGIN、ALLOW-FROM origin。用法参考:MDN文档

1.6.3【必须】外部输入拼接到HTTP响应头中需进行过滤

  • 应尽量避免外部可控参数拼接到HTTP响应头中,如业务需要则需要过滤掉\r、\n等换行符,或者拒绝携带换行符号的外部输入。

1.6.4【必须】外部输入拼接到response页面前进行编码处理

  • 直出html页面或使用模板生成html页面的,推荐使用text/template自动编码,或者使用html.EscapeString或text/template对<, >, &, ‘,”等字符进行编码。
1
2
3
4
5
6
7
8
9
10
import (
"html/template"
)

func outtemplate(w http.ResponseWriter, r *http.Request) {
param1 := r.URL.Query().Get("param1")
tmpl := template.New("hello")
tmpl, _ = tmpl.Parse(`{{define "T"}}{{.}}{{end}}`)
tmpl.ExecuteTemplate(w, "T", param1)
}

1.7 会话管理

1.7.1【必须】安全维护session信息

  • 用户登录时应重新生成session,退出登录后应清理session。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    import (
    "github.com/gorilla/handlers"
    "github.com/gorilla/mux"
    "net/http"
    )

    // 创建cookie
    func setToken(res http.ResponseWriter, req *http.Request) {
    expireToken := time.Now().Add(time.Minute * 30).Unix()
    expireCookie := time.Now().Add(time.Minute * 30)

    //...

    cookie := http.Cookie{
    Name: "Auth",
    Value: signedToken,
    Expires: expireCookie, // 过期失效
    HttpOnly: true,
    Path: "/",
    Domain: "127.0.0.1",
    Secure: true,
    }

    http.SetCookie(res, &cookie)
    http.Redirect(res, req, "/profile", 307)
    }

    // 删除cookie
    func logout(res http.ResponseWriter, req *http.Request) {
    deleteCookie := http.Cookie{
    Name: "Auth",
    Value: "none",
    Expires: time.Now(),
    }
    http.SetCookie(res, &deleteCookie)
    return
    }

1.7.2【必须】CSRF防护

  • 涉及系统敏感操作或可读取敏感信息的接口应校验Referer或添加csrf_token。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// good
import (
"github.com/gorilla/csrf"
"github.com/gorilla/mux"
"net/http"
)

func main() {
r := mux.NewRouter()
r.HandleFunc("/signup", ShowSignupForm)
r.HandleFunc("/signup/post", SubmitSignupForm)
// 使用csrf_token验证
http.ListenAndServe(":8000",
csrf.Protect([]byte("32-byte-long-auth-key"))(r))
}

1.8 访问控制

1.8.1【必须】默认鉴权

  • 除非资源完全可对外开放,否则系统默认进行身份认证,使用白名单的方式放开不需要认证的接口或页面。

  • 根据资源的机密程度和用户角色,以最小权限原则,设置不同级别的权限,如完全公开、登录可读、登录可写、特定用户可读、特定用户可写等

  • 涉及用户自身相关的数据的读写必须验证登录态用户身份及其权限,避免越权操作

    1
    2
    -- 伪代码
    select id from table where id=:id and userid=session.userid
  • 没有独立账号体系的外网服务使用QQ或微信登录,内网服务使用统一登录服务登录,其他使用账号密码登录的服务需要增加验证码等二次验证

1.9 并发保护

1.9.1【必须】禁止在闭包中直接调用循环变量

  • 在循环中启动协程,当协程中使用到了循环的索引值,由于多个协程同时使用同一个变量会产生数据竞争,造成执行结果异常。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// bad
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
var group sync.WaitGroup

for i := 0; i < 5; i++ {
group.Add(1)
go func() {
defer group.Done()
fmt.Printf("%-2d", i) // 这里打印的i不是所期望的
}()
}
group.Wait()
}

// good
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
var group sync.WaitGroup

for i := 0; i < 5; i++ {
group.Add(1)
go func(j int) {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered in start()")
}
group.Done()
}()
fmt.Printf("%-2d", j) // 闭包内部使用局部变量
}(i) // 把循环变量显式地传给协程
}
group.Wait()
}

1.9.2【必须】禁止并发写map

  • 并发写map容易造成程序崩溃并异常退出,建议加锁保护
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // bad
    func main() {
    m := make(map[int]int)
    // 并发读写
    go func() {
    for {
    _ = m[1]
    }
    }()
    go func() {
    for {
    m[2] = 1
    }
    }()
    select {}
    }

1.9.3【必须】确保并发安全

敏感操作如果未作并发安全限制,可导致数据读写异常,造成业务逻辑限制被绕过。可通过同步锁或者原子操作进行防护。

通过同步锁共享内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// good
var count int

func Count(lock *sync.Mutex) {
lock.Lock() // 加写锁
count++
fmt.Println(count)
lock.Unlock() // 解写锁,任何一个Lock()或RLock()均需要保证对应有Unlock()或RUnlock()
}

func main() {
lock := &sync.Mutex{}
for i := 0; i < 10; i++ {
go Count(lock) // 传递指针是为了防止函数内的锁和调用锁不一致
}
for {
lock.Lock()
c := count
lock.Unlock()
runtime.Gosched() // 交出时间片给协程
if c > 10 {
break
}
}
}
  • 使用sync/atomic执行原子操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// good
import (
"sync"
"sync/atomic"
)

func main() {
type Map map[string]string
var m atomic.Value
m.Store(make(Map))
var mu sync.Mutex // used only by writers
read := func(key string) (val string) {
m1 := m.Load().(Map)
return m1[key]
}
insert := func(key, val string) {
mu.Lock() // 与潜在写入同步
defer mu.Unlock()
m1 := m.Load().(Map) // 导入struct当前数据
m2 := make(Map) // 创建新值
for k, v := range m1 {
m2[k] = v
}
m2[key] = val
m.Store(m2) // 用新的替代当前对象
}
_, _ = read, insert
}

https://segmentfault.com/a/1190000041634906

了解概念

  • 类型形参(Type parameter)
  • 类型实参(Type argument)
  • 类型形参列表(Type parameter list)
  • 类型约束(Type constraint)
  • 实例化(Instantiations)
  • 泛型类型(Generic type)
  • 泛型接收器(Generic receiver)
  • 泛型函数(Generic function)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type MySlice[T int | string] []T

type MyMap[K string | int, V int | float32 | float64] map[K]V

type MyStruct[T int | string] struct {
Name string
Data T
}

type IPrintData[T int | float32 | string] interface {
Print(data T)
}

type MyChan[T int | string] chan T

// 类型形参可以互相套用
type WowStruct[T int | float32, S []T] struct {
Data S
MaxValue T
MinValue T
}

任何泛型类型都必须传入类型实参实例化才可以使用:

1
m := MyMap[string, int]{"a": 1, "b": 2}

注意:匿名结构体不支持泛型

几种常见错误

1、定义泛型类型的时候,基础类型不能只有类型形参。如下:

1
type CommonType[T int|string|float32] T   // 错误

2、当类型约束的一些写法会被编译器误认为是表达式时会报错。如下:

1
2
type NewType[T *int] []T    // 错误。T *int会被编译器误认为是表达式 T乘以int,而不是int指针
type NewType2[T *int|*float64] []T // 错误。和上面一样,这里不光*被会认为是乘号,| 还会被认为是按位或操作

为了避免这种误解,解决办法就是给类型约束包上 interface{} 或加上逗号消除歧义

1
2
type NewType[T interface{*int}] []T
type NewType2[T interface{*int|*float64}] []T

泛型 receiver

单纯的泛型类型实际上对开发来说用处并不大,但泛型类型和泛型 receiver 相结合,就有了非常大的实用性。

我们知道,定义了新的普通类型之后可以给类型添加方法。那么可以给泛型类型添加方法吗?答案自然是可以的,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type MySlice[T int | float32] []T

func (s MySlice[T]) Sum() T {
var sum T
for _, value := range s {
sum += value
}
return sum
}

//
var s MySlice[int] = []int{1, 2, 3, 4}
fmt.Println(s.Sum()) // 输出:10

var s2 MySlice[float32] = []float32{1.0, 2.0, 3.0, 4.0}
fmt.Println(s2.Sum()) // 输出:10.0

动态判断变量类型

使用接口的时候经常会用到类型断言type swith 来确定接口具体的类型,然后对不同类型做出不同的处理,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
var i interface{} = 123
i.(int) // 类型断言

// type switch
switch i.(type) {
case int:
// do something
case string:
// do something
default:
// do something
}
}

但是,对于 valut T 这样通过类型形参定义的变量,不能使用类型断言type swith 判断具体类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (q *Queue[T]) Put(value T) {
value.(int) // 错误。泛型类型定义的变量不能使用类型断言

// 错误。不允许使用type switch 来判断 value 的具体类型
switch value.(type) {
case int:
// do something
case string:
// do something
default:
// do something
}

// ...
}

可以通过反射机制达到目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (receiver Queue[T]) Put(value T) {
// Printf() 可输出变量value的类型(底层就是通过反射实现的)
fmt.Printf("%T", value)

// 通过反射可以动态获得变量value的类型从而分情况处理
v := reflect.ValueOf(value)

switch v.Kind() {
case reflect.Int:
// do something
case reflect.String:
// do something
}

// ...
}

这看起来达到了我们的目的,可是当你写出上面这样的代码时候就出现了一个问题:

你为了避免使用反射而选择了泛型,结果到头来又为了一些功能在在泛型中使用反射

当出现这种情况的时候你可能需要重新思考一下,自己的需求是不是真的需要用泛型(毕竟泛型机制本身就很复杂了,再加上反射的复杂度,增加的复杂度并不一定值得)

泛型函数

示例:

1
2
3
4
5
6
7
8
9
10
func Add[T int | float32 | float64](a T, b T) T {
return a + b
}

Add[int](1,2) // 传入类型实参int,计算结果为 3
Add[float32](1.0, 2.0) // 传入类型实参float32, 计算结果为 3.0

// Go还支持类型实参的自动推导
Add(1, 2) // 1,2是int类型,编译请自动推导出类型实参T是int
Add(1.0, 2.0) // 1.0, 2.0 是浮点,编译请自动推导出类型实参T是float32

注意:

  1. 匿名函数不支持泛型
  2. 虽然函数支持泛型,但是方法不支持泛型
1
2
3
4
5
6
7
type A struct {
}

// 不支持泛型方法
func (receiver A) Add[T int | float32 | float64](a T, b T) T {
return a + b
}

如果要想在方法中使用泛型的话,可以通过 receiver 间接实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type A[T int | float32 | float64] struct {
}

// 方法可以使用类型定义中的形参 T
func (receiver A[T]) Add(a T, b T) T {
return a + b
}

// 用法:
var a A[int]
a.Add(1, 2)

var aa A[float32]
aa.Add(1.0, 2.0)

接口更加复杂

有时候使用泛型编程时,我们会书写长长的类型约束,如下:

1
2
// 一个可以容纳所有int,uint以及浮点类型的泛型切片
type Slice[T int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64] []T

理所当然,这种写法是我们无法忍受也难以维护的,而 Go 支持将类型约束单独拿出来定义到接口中,从而让代码更容易维护:

1
2
3
4
5
type IntUintFloat interface {
int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64
}

type Slice[T IntUintFloat] []T

这段代码把类型约束给单独拿出来,写入了接口类型 IntUintFloat 当中。需要指定类型约束的时候直接使用接口 IntUintFloat 即可。

不过这样的代码依旧不好维护,而接口和接口、接口和普通类型之间也是可以通过 | 进行组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Int interface {
int | int8 | int16 | int32 | int64
}

type Uint interface {
uint | uint8 | uint16 | uint32
}

type Float interface {
float32 | float64
}

type Slice[T Int | Uint | Float] []T // 使用 '|' 将多个接口类型组合

或者:

1
2
3
4
5
type SliceElement interface {
Int | Uint | Float | string // 组合了三个接口类型并额外增加了一个 string 类型
}

type Slice[T SliceElement] []T

~ 指定底层类型

1
2
3
4
var s1 Slice[int]   // 正确

type MyInt int
var s2 Slice[MyInt] // ✗ 错误。MyInt类型底层类型是int但并不是int类型,不符合 Slice[T] 的类型约束

这里发生错误的原因是,泛型类型 Slice[T] 允许的是 int 作为类型实参,而不是 MyInt (虽然 MyInt 类型底层类型是 int ,但它依旧不是 int 类型)。

为了从根本上解决这个问题,Go 新增了一个符号 ~ ,在类型约束中使用类似 ~int 这种写法的话,就代表着不光是 int ,所有以 int 为底层类型的类型也都可用于实例化。

使用 ~ 对代码进行改写之后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Int interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}

type Uint interface {
~uint | ~uint8 | ~uint16 | ~uint32
}
type Float interface {
~float32 | ~float64
}

type Slice[T Int | Uint | Float] []T

var s Slice[int] // 正确

type MyInt int
var s2 Slice[MyInt] // MyInt底层类型是int,所以可以用于实例化

type MyMyInt MyInt
var s3 Slice[MyMyInt] // 正确。MyMyInt 虽然基于 MyInt ,但底层类型也是int,所以也能用于实例化

type MyFloat32 float32 // 正确
var s4 Slice[MyFloat32]

限制:使用 ~ 时有一定的限制:

  • ~后面的类型不能为接口
  • ~后面的类型必须为基本类型

从方法集(Method set)到类型集(Type set)

上面的例子中,我们学习到了一种接口的全新写法,而这种写法在 Go1.18 之前是不存在的。这意味着 Go 语言中 接口(interface) 这个概念发生了非常大的变化。

在 Go1.18 之前,Go 官方对 接口(interface)的定义是:接口是一个方法集(method set)。

从 Go1.18 开始,接口的定义正式更改为了 类型集(Type set)。

接口实现(implement)定义的变化

既然接口定义发生了变化,那么从 Go1.18 开始 接口实现(implement) 的定义自然也发生了变化。

当满足以下条件时,我们可以说 类型 T 实现了接口 I

  • T 不是接口时:类型 T 是接口 I 代表的类型集中的一个成员
  • T 是接口时: T 接口代表的类型集是 I 代表的类型集的子集

类型的交集

如果一个接口有多个类型的定义,取它们之间的 交集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type AllInt interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint32
}

type Uint interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

type A interface { // 接口A代表的类型集是 AllInt 和 Uint 的交集
AllInt
Uint
}

type B interface { // 接口B代表的类型集是 AllInt 和 ~int 的交集
AllInt
~int
}

上面的例子中:

  • 接口A代表的是AllIntUint交集,即:~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
  • 接口B代表的是AllInt~int的交集,即:~int

interface{}any

Go1.18 开始接口的定义发生了改变,所以 interface{} 的定义也发生了一些变更:

空接口代表了所有类型的集合

所以,对于 Go1.18 之后的空接口应该这样理解:

  1. 虽然空接口内没有写入任何的类型,但它代表的是所有类型的集合,而非一个 空集

  2. 类型约束中指定 空接口 的意思是指定一个包含所有类型的集合,并不是类型约束限制了只能使用 空接口 来做类型形参

    1
    2
    3
    4
    5
    6
    7
    // 空接口代表所有类型的集合。写入类型约束意味着所有类型都可拿来做类型实参
    type Slice[T interface{}] []T

    var s1 Slice[int] // 正确
    var s2 Slice[map[string]string] // 正确
    var s3 Slice[chan int] // 正确
    var s4 Slice[interface{}] // 正确

因为空接口是一个包含了所有类型的类型集,所以我们经常会用到它。于是,Go1.18 开始提供了一个和空接口 interface{} 等价的新关键词 any ,用来使代码更简单:

1
type Slice[T any] []T // 代码等价于 type Slice[T interface{}] []T

comparable(可比较)和 可排序(ordered)

对于一些数据类型,我们需要在类型约束中限制只接受能 !===对比的类型,如 map

1
2
// 错误。因为 map 中键的类型必须是可进行 != 和 == 比较的类型
type MyMap[K any, V any] map[K]V

所以 Go 直接内置了一个叫 comparable 的接口,它代表了所有可用 != 以及 == 对比的类型:

1
type MyMap[KEY comparable, VALUE any] map[KEY]VALUE  // 正确

注意:可比较指的是可以执行!=== 操作,并没有确保这个类型可以执行大小比较(> < >= <=

而可进行大小比较的类型被称为 Orderd 。目前 Go 并没有像 comparable 这样直接内置对应的关键词,所以想要的话需要自己来定义相关接口,比如我们可以参考golang.org/x/exp/constraints 如何定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Ordered 代表所有可比大小排序的类型
type Ordered interface {
Integer | Float | ~string
}

type Integer interface {
Signed | Unsigned
}

type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}

type Unsigned interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

type Float interface {
~float32 | ~float64
}

这里虽然可以直接使用官方包 golang.org/x/exp/constraints ,但因为这个包属于实验性质的 x 包,今后可能会发生非常大变动,所以并不推荐直接使用

接口的两种类型

1
2
3
4
5
6
type ReadWriter interface {
~string | ~[]rune

Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
}

我们用类型集的概念来理解这个接口的意思:

接口类型 ReadWriter 代表了一个类型集合,所有以 string[]rune 为底层类型,并且实现了 Read() Write() 这两个方法的类型都在 ReadWriter 代表的类型集当中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 类型 StringReadWriter 实现了接口 Readwriter
type StringReadWriter string

func (s StringReadWriter) Read(p []byte) (n int, err error) {
// ...
}

func (s StringReadWriter) Write(p []byte) (n int, err error) {
// ...
}

// 类型BytesReadWriter 没有实现接口 Readwriter
type BytesReadWriter []byte

func (s BytesReadWriter) Read(p []byte) (n int, err error) {
...
}

func (s BytesReadWriter) Write(p []byte) (n int, err error) {
...
}

现在,定义一个 ReadWriter 类型的接口变量,接口变量赋值的时候不光要考虑到方法的实现,还必须考虑到具体底层类型,心智负担太大。为了解决这个问题也为了保持 Go 语言的兼容性,Go1.18 开始将接口分为了两种类型:

  • 基本类型(Basic interface)
  • 一般接口(General interface)

基本接口(Basic interface)

接口定义中如果只有方法的话,那么这种接口被称为**基本接口(Basic interface)**。这种接口就是 Go1.18 之前的接口,用法也基本和 Go1.18 之前保持一致。基本接口大致可以用于如下几个地方:

  • 定义接口变量并赋值

    1
    2
    3
    4
    5
    6
    type MyError interface {  // 接口中只有方法,所以是基本接口
    Error() string
    }

    // 用法和Go1.18之前保持一致
    err := MyError
  • 用在类型约束中

    1
    type MySlice[T io.Reader | io.Writer] []Slice

一般接口(General interface)

接口中有类型的话,这种接口被称为 一般接口(General interface) ,如下例子都是一般接口:

1
2
3
4
5
6
7
8
9
10
type Uint interface {    // 接口 Uint 中有类型,所以是一般接口
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

type ReadWriter interface { // ReadWriter 接口既有方法也有类型,所以是一般接口
~string | ~[]rune

Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
}

一般接口类型不能用来定义变量,只能用于泛型的类型约束中。所以以下的用法是错误的:

1
2
3
4
5
type Uint interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

var uintInf Uint // 错误。Uint是一般接口,只能用于类型约束,不得用于变量定义

这一限制保证了一般接口的使用被限定在了泛型之中,不会影响到 Go1.18 之前的代码,同时也极大减少了书写代码时的心智负担。

泛型接口

1
2
3
4
5
6
7
8
9
10
11
type DataProcessor[T any] interface {
Process(oriData T) (newData T)
Save(data T) error
}

type DataProcessor2[T any] interface {
int | ~struct{ Data any }

Process(data T) (newData T)
Save(data T) error
}

接口定义的种种限制规则

Go1.18 从开始,在定义类型集(接口)的时候增加了非常多十分琐碎的限制规则,其中很多规则都在之前的内容中介绍过了,但剩下还有一些规则因为找不到好的地方介绍,所以在这里统一介绍下:

  1. | 连接多个类型的时候,类型之间不能有相交的部分(即必须是不交集):

    1
    2
    3
    4
    5
    6
    type MyInt int

    // 错误,MyInt的底层类型是int,和 ~int 有相交的部分
    type _ interface {
    ~int | MyInt
    }

    但是相交的类型中是接口的话,则不受这一限制:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    type MyInt int

    type _ interface {
    ~int | interface{ MyInt } // 正确
    }

    type _ interface {
    interface{ ~int } | MyInt // 也正确
    }

    type _ interface {
    interface{ ~int } | interface{ MyInt } // 也正确
    }
  2. 类型的并集中不能有类型形参

    1
    2
    3
    4
    5
    6
    7
    type MyInf[T ~int | ~string] interface {
    ~float32 | T // 错误。T是类型形参
    }

    type MyInf2[T ~int | ~string] interface {
    T // 错误
    }
  3. 接口不能直接或间接地并入自己

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    type Bad interface {
    Bad // 错误,接口不能直接并入自己
    }

    type Bad2 interface {
    Bad1
    }
    type Bad1 interface {
    Bad2 // 错误,接口Bad1通过Bad2间接并入了自己
    }

    type Bad3 interface {
    ~int | ~string | Bad3 // 错误,通过类型的并集并入了自己
    }
  4. 接口的并集成员个数大于一的时候不能直接或间接并入 comparable 接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    type OK interface {
    comparable // 正确。只有一个类型的时候可以使用 comparable
    }

    type Bad1 interface {
    []int | comparable // 错误,类型并集不能直接并入 comparable 接口
    }

    type CmpInf interface {
    comparable
    }
    type Bad2 interface {
    chan int | CmpInf // 错误,类型并集通过 CmpInf 间接并入了comparable
    }
    type Bad3 interface {
    chan int | interface{comparable} // 理所当然,这样也是不行的
    }
  5. 带方法的接口(无论是基本接口还是一般接口),都不能写入接口的并集中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    type _ interface {
    ~int | ~string | error // 错误,error是带方法的接口(一般接口) 不能写入并集中
    }

    type DataProcessor[T any] interface {
    ~string | ~[]byte

    Process(data T) (newData T)
    Save(data T) error
    }

    // 错误,实例化之后的 DataProcessor[string] 是带方法的一般接口,不能写入类型并集
    type _ interface {
    ~int | ~string | DataProcessor[string]
    }

    type Bad[T any] interface {
    ~int | ~string | DataProcessor[T] // 也不行
    }

总结

实际上推荐的使用场景也并没有那么广泛,对于泛型的使用,我们应该遵守下面的规则:

泛型并不取代 Go1.18 之前用接口+反射实现的动态类型,在下面情景的时候非常适合使用泛型:当你需要针对不同类型书写同样的逻辑,使用泛型来简化代码是最好的 (比如你想写个队列,写个链表、栈、堆之类的数据结构)

slice 是数组的引用,但是本身是结构体:

1
2
3
4
5
6
// runtime/slice.go
type slice struct {
array unsafe.Pointer // 指向slice中第一个元素的指针
len int // slice的长度,长度是下标操作的上界,如x[i]中i必须小于长度
cap int // slice的容量,容量是分割操作的上界,如x[i:j]中j不能大于容量。
}

len 和 cap

1
2
3
4
5
s := make([]string, 1, 3)
fmt.Println(s[0]) // ""
fmt.Println(len(s), cap(s)) // 1 3
s = append(s, "c", "d", "e", "f")
fmt.Println(len(s), cap(s)) // 4 6

创建 slice:

  1. 创建一个长度为cap的数组,如果不指定cap,则cap等于len;例如s := []string{"a","b","c"}lencap都是 3;
  2. 将数组前len个元素进行初始化,上例中数组第一个元素 k 初始化为空字符串;
  3. 返回。

slice 的分割

slice 的分割不涉及复制操作:它只是新建了一个结构来放置一个不同的指针,长度和容量:

分割表达式x[1:3]并不分配更多的数据:它只是创建了一个新的 slice 来引用相同的存储数据。

1
2
3
4
s1 := []int{1, 2, 3}
s2 := s1[0:] // 等价于 s2 = s1
s1[0] = 100
fmt.Println(s2) // [100 2 3]

修改 s1,也会影响到 s2。

字符串的分割也同理:

append 和 copy

append

现有的元素加上要添加的元素,长度不超过 cap,则不会发生扩容行为,只会修改被引用的数组和len

1
2
3
4
5
6
7
s1 := make([]int, 2, 100)
s2 := s1
s1 = append(s1, 1)
fmt.Printf("%p len:%d cap:%d\n", s1, len(s1), cap(s1)) // 0xc00010a000 len:3 cap:100
fmt.Printf("%p len:%d cap:%d\n", s2, len(s2), cap(s2)) // 0xc00010a000 len:2 cap:100
fmt.Println(s1) // [0 0 1]
fmt.Println(s2) // [0 0]

append 添加的元素太多,当前底层的数组不够用了,就会自动扩容,会复制被引用的数组,然后切断引用关系。

copy

上面的例子中:

1
2
3
4
s1 := []int{1, 2, 3}
s2 := s1[0:] // 等价于 s2 = s1
s1[0] = 100
fmt.Println(s2) // output:[100 2 3]

修改 s1,也会影响到 s2,如果想避免这种情况,需要使用copy(dst, src)

1
2
3
4
5
s1 := []int{1, 2, 3}
s2 := make([]int, len(s1))
copy(s2, s1)
s1[0] = 100
fmt.Println(s2) // output:[1 2 3]

slice 的扩容

在对 slice 进行 append 等操作时,可能导致 slice 会自动扩容,重新分配更大的数组。go1.18 之前其扩容的策略是:

  1. 如果新的大小是当前大小 2 倍以上,则大小增长为新大小;
  2. 否则循环操作:如果当前大小小于 1024,按每次 2 倍增长,否则每次按当前大小 1/4 增长。

go1.18 之后,优化了切片扩容的策略 2,让底层数组大小的增长更加平滑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
const threshold = 256
if old.cap < threshold {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices. This formula
// gives a smooth-ish transition between the two.
newcap += (newcap + 3*threshold) / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}

通过减小阈值并固定增加一个常数,使得优化后的扩容的系数在阈值前后不再会出现从 2 到 1.25 的突变,作者给出了几种原始容量下对应的“扩容系数”:

原始容量 扩容系数
256 2.0
512 1.63
1024 1.44
2048 1.35
4096 1.30

什么时候用 slice?

在 go 语言中 slice 是很灵活的,大部分情况都能表现的很好,但也有特殊情况。

当程序要求 slice 的容量超大并且需要频繁的更改 slice 的内容时,就不应该用 slice,改用list更合适。

转载:http://hmli.ustc.edu.cn/doc/linux/ubuntu-autoinstall/index.html

前言

Ubuntu 22.04下的PXE自动无人值守安装配置服务设置,原文内容比较简单,基本为翻译的 Ubuntu 22.04官方手册

    • 服务端提供PXE自动安装服务的信息:

      提供DHCPD服务的网卡:enp2s0IP:192.168.22.254掩码:255.255.255.0

    • 客户端(目标对象)网络启动引导的信息:

      安装系统名:Cleint1采用DHCP协议引导的网卡:enp1s0MAC地址:08:00:20:0A:0C:01自身IP:192.168.22.1网关IP:192.168.22.254掩码:255.255.255.0DNS:202.38.64.7

下述带有 1.、2. 编号的才是实际执行的步骤,其他都是介绍。

AMD64(x86_64)架构网络安装

AMD64(也称为x86_64)架构的系统启动既可以采用 UEFI 也可以采用传统 legacy (“BIOS”) 模式(很多系统可以被配置为采用其中任一模式启动)。确切的细节取决于系统固件,但两种模式都支持 PXE(“Preboot eXecution Environment”) 规范,使得可以通过网络来提供启动器引导加载程序启动主机。

两种模式的网络启动实时服务器安装程序的过程相似,如下所示:

  • 待安装机器启动,并定向到网络启动。
  • DHCP/bootp 服务器告诉机器它的网络配置以及从哪里获取引导加载程序。
  • 机器的固件通过 tftp 协议下载引导加载程序并执行它。
  • 引导加载程序通过 tftp 协议下载配置,告诉它在哪里下载内核(kernel)、 ramdisk 和内核命令行以使用。
  • ramdisk 查看内核命令行以了解如何配置网络以及从何处下载服务器 ISO 操作系统安装镜像文件。
  • ramdisk 下载操作系统 ISO 并将其安装为循环设备。
  • 从此时起,安装遵循与操作系统 ISO 在本地块设备上相同的路径。

UEFI 和传统模式之间的区别在于:在 UEFI 模式下,引导加载程序是 EFI 可执行文件,经过签名以便安全引导 SecureBoot 接受,而在传统模式下,它是 PXELINUX 。大多数 DHCP/bootp 服务器可以配置为为特定机器提供正确的引导加载程序。

设置DHPC/bootp和tftp服务

有几种可用的 DHCP/bootptftp 协议实现。本文档将简要介绍如何配置 dnsmasq 服务(相比配置 isc-dhcp-servertftpd 简单的多)以执行这两个角色。

  1. 创建存放为客户端提供 tftp 服务所需要的文件的目录 /srv/tftp

    1
    mkdir /srv/tftp
  2. 安装 dnsmasq 包,执行命令:

    1
    apt install dnsmasq 
  3. 设置文件 /etc/dnsmasq.conf.d/pxe.conf ,其内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #设置本服务器提供DHCP服务的网卡 
    interface=enp2s0,lo bind-interfaces
    #提供DHCP服务的网卡,开始IP,结束IP
    dhcp-range=enp2s0,192.168.22.1,192.168.22.254 dhcp-boot=pxelinux.0
    #设置采用UEFI模式 dhcp-match=set:efi-x86_64,option:client-arch,7 dhcp-boot=tag:efi-x86_64,bootx64.efi
    #启用tftp服务 enable-tftp
    #设置tftp服务目录 tftp-root=/srv/tftp
    #设定将要网络安装的MAC地址为08:00:20:0A:0C:01的主机名为Client1,IP为192.168.22.1
    dhcp-host=08:00:20:0A:0C:01,Client1,192.168.22.1
    #设定将要网络安装的MAC地址为08:00:20:0A:0C:02的主机名为Client2,IP为192.168.22.2
    dhcp-host=08:00:20:0A:0C:02,Client2,192.168.22.2

    上面 enp2s0 为对客户端安装服务时使用的网卡名(与客户端启动网络安装服务的网卡在同一网络)。

  4. 重启 dnsmasq 服务,执行命令:

    1
    systemctl restart dnsmasq

提供引导加载程序和配置

设置所需要的包

  1. 安装对应包

    1
    apt install cd-boot-images-amd64
  2. 生成软链接 /srv/tftp/boot-amd64指向 /usr/share/cd-boot-images-amd64

    1
    ln -s /usr/share/cd-boot-images-amd64 /srv/tftp/boot-amd64

安装WEB服务

安装时,需要通过 HTTP 协议下载所需文件,为此需要配置 WEB 服务。

安装所需要包,并重新启动服务:

1
2
apt install apache2 
systemctl restart apache2

或者不用上述 apache 服务,如python3支持http模块,则可直接执行下述命令启动简易 WEB 服务:

1
python3 -m http.server 80

模式独立设置

  1. 针对该次使用 Ubuntu 22.04 server 版(代号: jammy ),建议有些配置文件放置在目录 /var/www/html/jammy 下,为此先生成该目录:

    mkdir /var/www/html/jammy

  2. 下载所需要的操作系统 Live ISO 镜像到目录 /var/www/html

    1
    2
    cd /var/www/html/jammy
    wget http://mirrors.ustc.edu.cn/ubuntu-releases/jammy/ubuntu-22.04.1-live-server-amd64.iso
  3. 挂载该ISO文件:

    1
    mount -o loop /var/www/html/jammy/ubuntu-22.04.1-live-server-amd64.iso /mnt 
  4. 将内核和 initrd 从其中复制到 dnsmasq 中设置的 tftp 的位置:

    1
    cp /mnt/casper/{vmlinuz,initrd} /srv/tftp/

UEFI 引导设置文件

  1. 将签名的 shim 二进制文件复制到位:

    1
    2
    apt download shim-signed
    dpkg-deb --fsys-tarfile shim-signed*deb | tar x ./usr/lib/shim/shimx64.efi.signed -O > /srv/tftp/bootx64.efi
  2. 将签名的 grub 二进制文件复制到位:

    1
    2
    apt download grub-efi-amd64-signed
    dpkg-deb --fsys-tarfile grub-efi-amd64-signed*deb | tar x ./usr/lib/grub/x86_64-efi-signed/grubnetx64.efi.signed -O > /srv/tftp/grubx64.efi
  3. Grub 还需要在 tftp 上可用的字体:

    1
    2
    apt download grub-common
    dpkg-deb --fsys-tarfile grub-common*deb | tar x ./usr/share/grub/unicode.pf2 -O > /srv/tftp/unicode.pf2
  4. 生成文件 /srv/tftp/grub/grub.cfg ,内容为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    set default="0"
    set timeout=0

    if loadfont unicode ; then
    set gfxmode=auto
    set locale_dir=$prefix/locale
    set lang=en_US
    fi
    terminal_output gfxterm

    set menu_color_normal=white/black
    set menu_color_highlight=black/light-gray
    if background_color 44,0,30; then
    clear
    fi

    function gfxmode {
    set gfxpayload="${1}"
    if [ "${1}" = "keep" ]; then
    set vt_handoff=vt.handoff=7
    else
    set vt_handoff=
    fi
    }

    set linux_gfx_mode=keep

    export linux_gfx_mode

    menuentry 'Ubuntu 22.04.1' {
    gfxmode $linux_gfx_mode
    linux /vmlinuz $vt_handoff ip=dhcp url=http://192.168.22.254/jammy/ubuntu-22.04.1-live-server-amd64.iso autoinstall ds=nocloud-net\;s=http://192.168.22.254/jammy/ ---
    #注意上面最后面有三个-
    initrd /initrd
    }

    备注:

自动化服务器安装

介绍

20.04 版起,服务器安装程序支持一种新的操作模式:自动安装(简称 autoinstall ),此功能也被称为无人值守、甩手或预置安装。

自动安装使得用户可以通过提前配置自动安装配置文件回答所有这些安装配置问题,并让安装过程无需任何交互即可自行运行,适合批量安装多台主机。

debian-installer 预置安装的区别

preseeds 是基于 debian-installer (又名 d-i )自动化安装程序的方法。

Ubuntu新 autoinstall 方式的自动安装主要在以下方面不同于 preseeds

  • 格式完全不同( cloud-init config 格式,通常是 yaml 格式,vs debconf-set-selections 格式)

  • 当一个问题的答案不存在于 preseeds 中时, d-i 停止执行并要求用户输入。 autoinstalls 自动安装不是这样的:默认情况下,如果有任何自动安装配置,安装程序会为任何未回答的问题采用默认设置(如没有默认设置则失败)。

    用户可以将配置中的特定部分指定为“交互式”,这意味着安装程序仍会停止并询问这些部分。

提供自动安装配置

自动安装配置是通过 cloud-init 配置提供的,几乎无限灵活。在大多数情况下,最简单的方法是通过 nocloud 数据源提供用户数据。

自动安装配置应在配置中的关键字 autoinstall 下提供,如:

1
2
3
4
#cloud-config
autoinstall:
version: 1
...

运行真正的自动安装

即使找到完全非交互式的自动安装配置,服务器安装程序也会在写入磁盘之前要求确认,除非内核命令行上存在自动安装关键字 autoinstall 。这是为了避免意外创建一个U盘,结果该U盘会在主机启动时重新格式化它插入的机器。许多自动安装将通过 netboot 完成,其中内核命令行由 netboot 配置控制 —— 只需记住将自动安装放在那里!

快速开始

如只是想试试看,可以参看页面 https://ubuntu.com/server/docs/install/autoinstall-quickstart

创建自动安装配置

当服务器安装好Ubuntu操作系统时,会在安装好后的服务器上创建一个可用于重复安装的自动安装文件 /var/log/installer/autoinstall-user-data ,这可用于做后续需要的文件 user-data 的模板。

转换现有 preseed 文件

如已经有了Debian系统的 preseed 文件,可利用自动安装生成器 autoinstall-generator snap 包(snap 是一种包管理器,可用于安装远程snap包)可以帮助将该预置数据转换为自动安装文件。

安装 autoinstall-generator 包,执行:

1
snap install autoinstall-generator

preseed 文件转换为自动安装格式的基本用法为:

1
autoinstall-generator my-preseed.txt my-cloud-config.yaml --cloud

有关详细信息,请 参阅

自动安装配置文件的结构

自动安装配置的完整说明参见:https://ubuntu.com/server/docs/install/autoinstall-reference

从技术上讲,虽然配置未定义为文本格式,但 cloud-init 配置通常以 YAML 形式提供,这是本文档使用的语法。

一个最小的配置是:

1
2
3
4
5
version: 1
identity:
hostname: hostname
username: username
password: $crypted_pass

含有更多特性的配置为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
version: 1
reporting:
hook:
type: webhook
endpoint: http://example.com/endpoint/path
early-commands:
- ping -c1 198.162.1.1
locale: en_US
keyboard:
layout: gb
variant: dvorak
network:
network:
version: 2
ethernets:
enp0s25:
dhcp4: yes
enp3s0: {}
enp4s0: {}
bonds:
bond0:
dhcp4: yes
interfaces:
- enp3s0
- enp4s0
parameters:
mode: active-backup
primary: enp3s0
proxy: http://squid.internal:3128/
apt:
primary:
- arches: [default]
uri: http://repo.internal/
sources:
my-ppa.list:
source: "deb http://ppa.launchpad.net/curtin-dev/test-archive/ubuntu $RELEASE main"
keyid: B59D 5F15 97A5 04B7 E230 6DCA 0620 BBCF 0368 3F77
storage:
layout:
name: lvm
identity:
hostname: hostname
username: username
password: $crypted_pass
ssh:
install-server: yes
authorized-keys:
- $key
allow-pw: no
snaps:
- name: go
channel: 1.14/stable
classic: true
debconf-selections: |
bind9 bind9/run-resolvconf boolean false
packages:
- libreoffice
- dns-server^
user-data:
disable_root: false
late-commands:
- sed -ie 's/GRUB_TIMEOUT=.*/GRUB_TIMEOUT=30/' /target/etc/default/grub
error-commands:
- tar c /var/log/installer | nc 192.168.0.1 1000

许多关键字和值一般直接对应于安装程序提出的问题(例如键盘选择),请参阅相关资料。

错误处理

安装过程中的进度及出错信息,可以通过报告 reporting 关键字设定的系统获取。此外,当发生致命错误时,将执行错误命令 error-commands 并将回溯打印到控制台,然后服务器等待人为交互式干预。

未来可能的方向

可能希望扩展磁盘的 match specs (匹配规范)以涵盖选择磁盘的其他方式。

自动安装快速入门

cloud-init 使用以下三种数据并对其进行操作。

  • 用户数据 user-data :包含执行无人值守自动安装所需的指令,如要安装的软件包、分区布局、网络配置等。
  • 元数据 meta-data :包括与特定数据源关联的数据,如可以包括服务器名称和实例ID等。
  • 供应商数据 vendor-data :由组织(如,云提供商)提供的,包括可以自定义镜像以更好地适应镜像运行环境的信息,该种数据为可选。
  1. 设置用户数据文件 /var/www/html/jammy/user-data (可以采用文件 /var/log/installer/autoinstall-user-data 为模板修改),其内容为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    #cloud-config
    autoinstall:
    version: 1
    late-commands:
    - mkdir /target/root/.ssh && echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDZUS2FXGm2yK09Tv0biWIwLszp0TPGzB2u1PD/QXCGPimLOmEP2xAFJbgwzeXlgfnj01LP7BD65lidSPd68WwaUsNHdlikfwf9iRdlSXxNg+PVueY2KPHRzgQ5omtAzJFUUnHKIOSH/Ozo9tmwrs4tZc4CNpx44InvgQY3yeKfhqvVn2O+WazirNlGsFRcpMcjLJjZ+U2OfMGM4l0soSDJKJTKoaHmV0XpIOA2iqfQmOgFDZExqmjc8Vj/1hTBvTzyhgMPlzkDz28UsuBT3U+RZiZJokPtXks5mnVkVYzQvOkln3r0+bmOX0Q4qDg2HhinSpGXAZGln1PKH3AFU7StMIg6e1Lb8AMNHNxWBR61uXIwJJw3dcP6Sy3N4Gq3WW+hkVsHJgZRD+dPQNRwMHIY1CIfBtB9TySKpwNwSAeXiBPFoYgAi0RlbrdHLpjDl9LxK/vyZ1gvGSumtU9IM01EZWfXfCKoMX68ljIaVsHHhHbqIXSS7AjaEsJO+ss8nQU= root@ubuntu22-server.hanhai22.ustc.edu.cn" >/target/root/.ssh/authorized_keys
    apt:
    disable_components: []
    geoip: true
    preserve_sources_list: false
    primary:
    - arches:
    - amd64
    - i386
    uri: http://mirrors.ustc.edu.cn/ubuntu
    - arches:
    - default
    uri: http://ports.ubuntu.com/ubuntu-ports
    drivers:
    install: false
    identity:
    hostname: Client1.hanhai.scc.ustc.edu.cn
    password: $6$dIUrqz$MJN/0cc47EFl8OJBsbxm/o37ScqlZtXb8r63rGa0JLnbwpYQSLrCq7qgEcOAJc6RlkNbIicK6VlPPK402FD7wMQv.sHqNIl1
    realname: hmli
    username: hmli
    kernel:
    package: linux-generic
    keyboard:
    layout: us
    toggle: null
    variant: ''
    locale: en_US.UTF-8
    network:
    ethernets:
    enp1s0:
    addresses:
    - 192.168.22.1/24
    gateway4: 192.168.22.254
    nameservers:
    addresses:
    - 202.38.64.7
    search:
    - ustc.edu.cn
    version: 2
    ssh:
    allow-pw: true
    authorized-keys: []
    install-server: true
    storage:
    config:
    - ptable: gpt
    # serial: c9d3b07e01624ab69df6
    path: /dev/vda
    wipe: superblock
    preserve: false
    name: ''
    grub_device: false
    type: disk
    id: disk-vda
    - device: disk-vda
    size: 999292928
    wipe: superblock
    flag: boot
    number: 1
    preserve: false
    grub_device: true
    type: partition
    id: partition-0
    - fstype: fat32
    volume: partition-0
    preserve: false
    type: format
    id: format-0
    - device: disk-vda
    size: 1902116864
    wipe: superblock
    flag: ''
    number: 2
    preserve: false
    grub_device: false
    type: partition
    id: partition-1
    - fstype: ext4
    volume: partition-1
    preserve: false
    type: format
    id: format-1
    - device: disk-vda
    size: 18571329536
    wipe: superblock
    flag: ''
    number: 3
    preserve: false
    grub_device: false
    type: partition
    id: partition-2
    - name: ubuntu-vg
    devices:
    - partition-2
    preserve: false
    type: lvm_volgroup
    id: lvm_volgroup-0
    - name: ubuntu-lv
    volgroup: lvm_volgroup-0
    size: 10737418240B
    wipe: superblock
    preserve: false
    type: lvm_partition
    id: lvm_partition-0
    - fstype: ext4
    volume: lvm_partition-0
    preserve: false
    type: format
    id: format-2
    - path: /
    device: format-2
    type: mount
    id: mount-2
    - path: /boot
    device: format-1
    type: mount
    id: mount-1
    - path: /boot/efi
    device: format-0
    type: mount
    id: mount-0
    updates: security

    备注:late-commands 等中的字符串在传递给执行安装的主机时,有些特殊字符串需要特殊处理。

  2. 生成所需要的文件 meta-data (空文件即可),执行命令:

    1
    touch /var/www/html/jammy/meta-data
  3. 供应商数据文件 vendor-data 非必需,可不管。

  4. 确保目录 /var/www/html/jammy/ 下的文件对所有人可读:

    1
    chmod -R a+r /var/www/html/jammy/

上述各项含义参见: 自动服务器安装配置文件参考

备注:做好上述工作后,同一子网内的客户机启动采用PXE引导时,即可自动安装配置所需要的系统。

自动服务器安装配置文件参考

整体格式(Overall format)

自动安装 autoinstall 文件是 YAML 格式的。在顶层,它必须是包含本文档中描述的关键字 key 的映射。无法识别的关键字将被忽略。

概要(Schema)

自动安装配置在使用前会根据 JSON 模式进行转换并进行验证。

命令列表(Command lists)

几个配置关键字是要执行的命令列表。每个命令可以是字符串(在这种情况下通过 sh -c 执行)或列表,在这种情况下直接执行。任何以非零返回码退出的命令都被视为错误并中止安装(错误命令除外,它被忽略)。

顶级关键字(Top-level keys)

  • version

    • 名称:版本

    • 类型:整型

    • 默认值:无

      为将来不同版本准备的版本信息,目前只能为 1

  • interactive-sections

    • 名称:交互式部分

    • 类型:字符串列表

    • 默认值:[]

      仍然显示在用户界面 UI 中的配置键列表,如:

      1
      2
      3
      4
      5
      6
      version: 1
      interactive-sections:
      - network
      identity:
      username: ubuntu
      password: $crypted_pass

      交互式操作,将在网络屏幕上停止并允许用户更改默认值。如果为交互式部分提供了值,则将其用作默认值。

      可以使用特殊的块名称 “*” 来指示安装程序应该询问所有常见问题——在这种情况下,文件 autoinstall.yaml 根本不是真正的“自动安装”文件,而只是一种用于更改用户界面中的默认值的文件。

      并非所有配置关键字都对应于用户界面中的屏幕。该文档指示给定块是否可以交互。

      如果有任何交互块,则忽略报告关键字 reporting

  • early-commands

    • 名称:早期命令

    • 默认值:无

    • 是否可交互:不可

      安装程序启动后立即调用的shell命令列表,特别是在执行探测块和网络设备操作之前执行。自动安装配置在 /autoinstall.yaml 中可用(不管它是如何被提供),并且在早期命令 early-commands 运行后将重新读取该文件,以允许它们在必要时更改配置。

  • locale

    名称:语言环境

    类型:字符串

    默认值:en_US.UTF-8

    是否可交互:可,对于任何块中是的话,总是可以交互

    为已安装系统配置的语言环境。

  • refresh-installer

    • 名称:更新安装
    • 类型:映射
    • 默认值:参见下面
    • 是否可交互:可

    控制安装程序在继续之前是否更新到给定频道 channel 中可用的新版本。

    映射包含关键字:

    • update

      • 名称:更新
      • 类型:布尔型
      • 默认值:无

      控制是否执行系统更新。

    • channel

      • 名称:频道、通道
      • 类型:字符串
      • 默认值:stable/ubuntu-$REL

      用于检查系统更新的频道。

  • keyboard

    • 名称:键盘
    • 类型:映射,参见下面
    • 默认值:US English keyboard
    • 是否可交互:可

    任何附属键盘的布局。通常自动安装的系统根本没有键盘,在这种情况下,此处使用的值无关紧要。

    映射的关键字对应于 /etc/default/keyboard 配置文件中的设置。有关更多详细信息,请参阅其手册页。

    映射包含关键字:

    • layout

      • 名称:布局
      • 类型:字符串
      • 默认值:us

      对应于键盘 XKBLAYOUT 设置。

    • variant

      • 名称:变种

      • 类型:字符串

      • 默认值:””

      对应于键盘 XKBVARIANT 设置。

    • toggle

      • 名称:切换
      • 类型:字符串或null
      • 默认值:null

      对应于 grp 值: 来自键盘 XKBOPTIONS 设置的选项的值。可接受的值是(但请注意,安装程序不会验证这些):caps_toggle、toggle、rctrl_toggle、rshift_toggle、rwin_toggle、menu_toggle、alt_shift_toggle、ctrl_shift_toggle、ctrl_alt_toggle、alt_caps_toggle、lctrl_lshift_toggle、lalt_toggle、lctrl_toggle、lwinshift_toggle

    与20.04 GA一起发布的 subiquity 版本由于一个bug不接受该字段为null。

  • network

    • 名称:网络
    • 类型:netplan-format映射,参看下面
    • 默认值:用于DHCP协议的名字为ethen的网卡
    • 是否可交互:可

    netplan 格式的网络配置。将在安装期间以及已安装的系统中应用。默认是解释安装媒介的配置,它在名称匹配 “eth*”“en*” 的任何网卡上运行 DHCPv4 请求,并随后禁用任何未获取到IP地址的网卡。

    例如,要在特定网卡 enp0s31f6 上运行 dhcp6 请求:

    1
    2
    3
    4
    5
    network:
    version: 2
    ethernets:
    enp0s31f6:
    dhcp6: yes

    请注意,由于一个错误,随 20.04 GA 发布的 subiquity 版本强制您使用额外的 “network:” 关键字编写此代码,如下所示:

    1
    2
    3
    4
    5
    6
    7
    network:
    #多了上一层network:
    network:
    version: 2
    ethernets:
    enp0s31f6:
    dhcp6: yes

    更高版本也支持此语法(以实现兼容性),但如果可以确定采用修复后的新版本,则应使用前者(无需使用额外的 “network:” 关键字)。

  • proxy

    • 名称:代理
    • 类型:URL或null
    • 默认值:null
    • 是否可交互:可

    在安装期间以及在目标系统中为 aptsnapd 配置的代理,以便访问网络。

  • apt

    • 名称:APT高级包管理工具
    • 类型:映射
    • 默认值:参看下面
    • 是否可交互:可

    APT配置,在安装期间和引导到目标系统后都使用。

    这使用与 https://curtin.readthedocs.io/en/latest/topics/apt_source.html 中描述的 curtin (the curt installer) 安装格式相同的格式,但有一个扩展:关键字 geoip 控制是否完成地理IP(geoip)查找。

    默认值为:

    1
    2
    3
    4
    5
    6
    7
    8
    apt:
    preserve_sources_list: false
    primary:
    - arches: [i386, amd64]
    uri: "http://archive.ubuntu.com/ubuntu"
    - arches: [default]
    uri: "http://ports.ubuntu.com/ubuntu-ports"
    geoip: true

    如关键字 geoiptrue 并且要使用的镜像源是默认值( http://archive.ubuntu.com/ubuntuhttp://ports.ubuntu.com/ubuntu-ports 等),则向 https://geoip.ubuntu.com/lookup 发出请求,并且将使用的镜像URI更改为 http://CC.archive.ubuntu.com/ubuntu , 其中 CC 是查找返回的国家代码(或类似的端口,对于中国为 CN ),这将使用所在国家区域的源,提升网络更新速度。如果此部分不是交互式的,则请求会在10秒后超时。

    任何提供的配置都会与默认配置合并,而不是替换它。

    如果您只想设置镜像源,请使用如下配置:

    1
    2
    3
    4
    apt:
    primary:
    - arches: [default]
    uri: YOUR_MIRROR_GOES_HERE

    增加一个 PPA 源:

    1
    2
    3
    4
    apt:
    sources:
    curtin-ppa:
    source: ppa:curtin-dev/test-archive
  • storage

    • 名称:存储
    • 类型:映射,参看下面
    • 默认值:对于单块硬盘为 lvm ,对于多块硬盘则无默认值
    • 是否可交互:可

    存储配置是一个复杂的话题,自动安装文件中所需配置的描述也可能很复杂。安装程序支持“布局layouts”,即表达常见配置的简单方法。

    • 支持的布局

      目前支持的布局就两种,分别是逻辑卷模式 lvm 和直通模式 direct

      1
      2
      3
      4
      5
      6
      storage:
      layout:
      name: lvm
      storage:
      layout:
      name: direct

      默认情况下,这些将安装到系统中容量最大的磁盘,但您可以提供匹配规范(“match: {}”,见下文)来指示要使用的磁盘:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      storage:
      layout:
      name: lvm
      match:
      serial: CT*
      storage:
      layout:
      name: disk
      match:
      ssd: yes

      (可以用“match: {}”来匹配任意磁盘)

      默认采用 lvm

    • 基于动作的配置

      为了获得完全的灵活性,安装程序允许使用一种语法完成存储配置,该语法是 curtin 支持的语法的超集,在 https://curtin.readthedocs.io/en/latest/topics/storage.html 中进行了描述。

      如果使用 layout 功能配置磁盘,则不会使用 config 部分。

      除了将操作列表放在关键字 config 下之外, grubswap curtin配置项也可以放在此处。因此存储部分可能如下所示:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      storage:
      swap:
      size: 0
      config:
      - type: disk
      id: disk0
      serial: ADATA_SX8200PNP_XXXXXXXXXXX
      - type: partition
      ...

      Curtin语法的扩展围绕磁盘选择和分区/逻辑卷大小调整。

    • 磁盘选择扩展

      Curtin支持通过串行(如 Crucial_CT512MX100SSD1_14250C57FECE )或路径(如 /dev/sdc )识别磁盘,服务器安装程序也支持这一点。安装程序还支持磁盘操作上的 match spec ,支持更灵活的匹配。

      存储配置中的操作按照它们在自动安装文件中的顺序进行处理。任何磁盘操作都会被分配一个匹配的磁盘——如果有多个磁盘,则从一组未分配的磁盘中任意选择,如果没有未分配的匹配磁盘,则会导致安装失败。

      匹配规范支持以下关键字: - model: foo :匹配 udevID_VENDOR=foo 的磁盘,支持通配符 - path: foo :匹配 udevDEVPATH=foo 的磁盘,支持通配符(通配符支持将此与直接在磁盘操作中指定 path: foo 区分开来) - serial: foo :匹配 udevID_SERIAL=foo 的磁盘,支持通配符(通配符支持将此与直接在磁盘操作中指定 serial: foo 区分开来) - ssd: yes|no :匹配是或不是 SSD 的磁盘(相对于机械硬盘) - size: largest|smallest :如果有多个匹配项,则取最大或最小的磁盘而不是任意一个(在 20.06.1 版本中添加了对最小 smallest 的支持)

      因此,例如,要匹配任意磁盘,只需:

      1
      2
      - type: disk
      id: disk0

      匹配容量最大的SSD硬盘:

      1
      2
      3
      4
      5
      - type: disk
      id: big-fast-disk
      match:
      ssd: yes
      size: largest

      匹配希捷Seagate硬盘:

      1
      2
      3
      4
      - type: disk
      id: data-disk
      match:
      model: Seagate
    • 分区/逻辑卷扩展

      curtin中的分区或逻辑卷的大小指定为字节数。自动安装配置更加灵活:

      • 您可以使用安装程序用户界面中支持的“1G”、“512M”语法指定大小
      • 您可以将大小指定为包含磁盘(或RAID)的百分比,例如“50%”
      • 对于为特定设备指定的最后一个分区,您可以将大小指定为“-1”以指示该分区应填充剩余空间。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      - type: partition
      id: boot-partition
      device: root-disk
      size: 10%
      - type: partition
      id: root-partition
      size: 20G
      - type: partition
      id: data-partition
      device: root-disk
      size: -1
  • identity

    • 名称:身份
    • 类型:映射,参看下面
    • 默认值:无
    • 是否可交互:可

    配置系统的初始用户。这是唯一必须存在的配置关键字(除非存在用户数据部分,在这种情况下它是可选的)。

    可以包含关键字的映射,所有关键字都采用字符串值:

    • realname:实际名
    • username:用户名
    • hostname: 主机名
    • password:密码,加密的。这是与 sudo 一起使用时所必需的,即使配置了 SSH 访问。
  • ssh

    • 名称: SSH 服务
    • 类型:映射,参看下面
    • 默认值:参看下面
    • 是否可交互:可

    为已安装的系统配置 SSH 服务。可以包含关键字的映射:

    • install-server

      • 名称:安装 SSH 服务
      • 类型:布尔型
      • 默认值:false,不安装

      是否安装 OpenSSH 服务。

    • authorized-keys

      • 名称: SSH 认证公钥
      • 类型:字符串列表
      • 默认值:[]

      要安装在初始用户帐户中的 SSH 公钥列表,方便其他主机采用密钥通过 SSH 访问该主机。

    • allow-pw

      • 名称:是否允许密码
      • 类型:布尔型
      • 默认值:当 authorized_keys 为空时为真 true ,否则为否 false
  • snaps

    • 名称:snap包
    • 类型:列表
    • 默认值:不安装其他snap
    • 是否可交互:可

    要安装的snap包列表。每个snap都表示为具有必需的关键字 name 和可选的关键字 chanel (默认为 stable )和 classic (经典默认为 false )的映射。如:

    1
    2
    3
    4
    snaps:
    - name: etcd
    channel: edge
    classic: false
  • debconf-selections

    • 名称:采用 debconf 设置包选择
    • 类型:字符串
    • 默认值:无
    • 是否可交互:否

    安装程序将使用 debconf 设置选择值更新目标。用户需要熟悉软件包 debconf 选项。

  • packages

    • 名称:软件包
    • 类型:列表
    • 默认值:无软件包
    • 是否可交互:否

    要安装到目标系统中的软件包列表。更准确地说,是传递给命令 apt-get install 的字符串列表,因此这包括任务选择( dns-server^ )和安装特定版本的包( my-package=1-1 )。

  • late-commands

    • 名称:后期命令
    • 类型:命令列表
    • 默认值:无命令
    • 是否可交互:否

    在安装成功完成并安装任何更新和软件包之后运行的 Shell 命令,就在系统重新启动之前。它们在安装程序环境中运行,已安装的系统安装在目录 /target 。您可以运行 curtin in-target -- $shell_command (使用20.04 GA发布的subiquity安装程序版本,采用 curtin 格式,您需要将其指定为 curtin in-target --target=/target -- $shell_command )以在目标系统中运行(类似了解如何在 d-i preseed/late_command 中使用简单的目标内)。

  • error-commands

    • 名称:出错处理命令
    • 类型:命令列表
    • 默认值:无命令
    • 是否可交互:否

    安装失败后运行的Shell命令。它们在安装程序环境中运行,并且目标系统(或安装程序设法配置的尽可能多的系统)将安装在目录 /target 。日志将在实时会话中的目录 /var/log/installer 中可用。

  • reporting

    • 名称:报告
    • 类型:映射
    • 默认值: type: print ,导致tty1和任何已配置的串行控制台上的输出
    • 是否可交互:否

    安装程序支持向各种目的地报告进度。请注意,如果有任何交互部分,则忽略此部分;它仅适用于全自动安装。

    配置,实际上实现,与curtin使用的90%相同。

    配置中报告映射中的每个键都定义了一个目标,其中子关键字 type 是以下之一:

    (原文在该处有 The rsyslog reporter does not yet exist ,没理解为什么在这个位置说这个,也不清楚什么含义)

    • print :在tty1和任何配置的串行控制台上打印进度信息。没有其他配置。
    • rsyslog :通过rsyslog报告进度。目标键指定将输出发送到何处。
    • webhook :通过将JSON报告发布到 URL 来报告进度。接受与curtin相同的配置。
    • none :不报告进度。仅对禁止默认输出有用。

    例子:

    默认配置:

    1
    2
    3
    reporting:
    builtin:
    type: print

    输出到 rsyslog

    1
    2
    3
    4
    reporting:
    central:
    type: rsyslog
    destination: @192.168.0.1

    抑制默认输出:

    1
    2
    3
    reporting:
    builtin:
    type: none

    输出到 curtin 样式的 webhook

    1
    2
    3
    4
    5
    6
    7
    8
    9
    reporting:
    hook:
    type: webhook
    endpoint: http://example.com/endpoint/path
    consumer_key: "ck_foo"
    consumer_secret: "cs_foo"
    token_key: "tk_foo"
    token_secret: "tk_secret"
    level: INFO
  • user-data

    • 名称:用户数据
    • 类型:映射
    • 默认值:{}
    • 是否可交互:否

    提供 cloud-init 用户数据,它将与安装程序生成的用户数据 user-data 合并。如果您提供此信息,则无需提供身份 ** identity** 部分(但您有责任确保您可以登录到已安装的系统!)。

参考

数据加密

加密:数据使用对称加密,对称加密的密钥使用公钥加密

解密:先使用私钥解密 AES 密钥,然后用 AES 密钥解密数据

数字签名

计算数据的哈希值,然后对哈希值使用私钥加密,就得到了 数字签名

数据加密+数字签名

数据加密 无法验证数据完整性和来源。

数字加密 + 数字签名,既实现数据加密,又可以保证数据来源的可靠性、数据的完整性和一致性。

加密:

解密:

得到原文与原文哈希,然后计算原文哈希,与传过来的原文哈希进行对比,如果一致,说明原文是完整可靠的。