阅读源码,个人觉得更多的收获是你从源码中提炼到了什么知识点,Vue的很多核心源码都十分精妙,让我们一起来关注它「依赖收集」的实现。
**tip:Vue版本:v2.6.12,浏览器:谷歌,阅读方式:在静态html 引用 Vue 包<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js"></script>
进行断点阅读**
文章篇幅有点长,泡杯咖啡,慢慢看 ~
我从「依赖收集」中学习到了什么?
1. 观察者模式
观察者模式的基本概念:
观察目标发生变化 -> notify[通知] -> 观察者们 -> update[更新]
下面这段代码是 Vue 源码中经过运算的结果,可以让小伙伴们的脑袋瓜先有个简单的结构:
名词解释:
dep:depend[依赖],这里的“依赖”,我们可以理解成 “观察目标” 。
subs:subscribers[订阅者],这里的“订阅者”等价“观察者”。
// 基础数据
data: {
a: 1, // 关联 dep:id=0 的对象,a如果发生变化,this.a=3,调用 notify,
b: 2, // 关联 dep:id=1 的对象...
// ...
}
dep = {
id: 0,
// 通知观察者们
notify() {
this.subs.forEach(item => {
item.update();
});
},
// 观察者们
subs: [
{
id: 1,
update() {
// 被目标者通知,做点什么事
}
},
{
id: 2,
update() {
// 被目标者通知,做点什么事
}
}
]
};
dep = {
id: 1,
//...
2. defineProperty 对一级/多级对象进行拦截
对于一级对象的拦截相信小伙伴们都会啦。
这里阐述一下对于多级对象设置拦截器的封装,看下这段代码:
const obj = { message: { str1: 'hello1', str2: 'hello2' } };
function observer(obj) {
if (!(obj !== null && typeof obj === 'object')) {
return;
}
walk(obj);
}
function walk(obj) {
let keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i]);
}
}
function defineReactive(obj, key) {
let val = obj[key];
observer(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
console.log('get :>> ', key, val);
return val;
},
set(newVal) {
console.log('set :>> ', key, newVal);
observer(newVal);
val = newVal;
}
});
}
observer(obj);
解释:observer
这个方法表示如果当前是一个对象,就会继续被遍历封装拦截。
我们对 obj 进行操作,看控制台的输出:
obj.message
// get :>> message { str1: "hello1", str2: "hello2"}
/* 这个例子说明了:不管是在 get/set str1,都会先触发 message 的 get*/
obj.message.str1
// get :>> message { str1: "hello1", str2: "hello2" }
// get :>> str1 hello1
obj.message.str1="123"
// get :>> message { str1: "123", str2: "hello2" }
// set :>> str1 123
// 重点:
obj.message={test: "test"}
// set :>> message { test: "test" }
obj.message.test='test2'
// get :>> message { test: "test2" }
// set :>> test test2
/*
有些小伙伴可能会有疑惑,这里进行 obj.message={test: "test"} 赋值一个新对象的话,
不就无法检测到属性的变化,为什么执行 obj.message.test='test2' 还会触发到 set 呢?
返回到上面,在 defineReactive 方法拦截器 set 中,我们做了这样一件事:
set(newVal) {
// 这里调用 observer 方法重新遍历,如果当前是一个对象,就会继续被遍历封装拦截
observer(newVal)
// ...
}
*/
延伸到实际业务场景:「获取用户信息然后进行展示」。我在 data 设置了一个 userInfo: {}
,ajax 获取到结果进行赋值 this.userInfo = { id: 1, name: 'refined' }
,就可以显示到模板 {{ userInfo.name }}
,之后再进行 this.userInfo.name = "xxx"
,也会进行响应式渲染了。
3. defineProperty 对数组的拦截丨Object.create 原型式继承丨原型链丨AOP
我们都知道 defineProperty 只能拦截对象,对于数组的拦截 Vue 有巧妙的扩展:
var arrayProto = Array.prototype;
var arrayMethods = Object.create(arrayProto);
var methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
];
methodsToPatch.forEach(function (method) {
var original = arrayProto[method];
Object.defineProperty(arrayMethods, method, {
enumerable: true,
configurable: true,
value: function mutator(...args) {
console.log('set and do something...');
var result = original.apply(this, args);
return result;
}
});
});
function protoAugment(target, src) {
target.__proto__ = src;
}
var arr = [1, 2, 3];
protoAugment(arr, arrayMethods);
arr.push(4)
// set and do something...
解释:Object.create(arrayProto);
为原型式继承,即 arrayMethods.__proto__ === Array.prototype === true
,所以现在的 arrayMethods
就可以用数组的所有方法。
代码中的 target.__proto__ = src
,即 arr.__proto__ = arrayMethods
,我们已经对 arrayMethods 自己定义了几个方法了,如 push。
现在我们进行 arr.push
,就可以调用到 arrayMethods
自定义的 push 了,内部还是有调用了 Array.prototype.push
原生方法。这样我们就完成了一个拦截,就可以检测到数组内容的修改。
原型链机制:Array.prototype
本身是有 push 方法的,但原型链的机制就是,arr 通过 __proto__
找到了 arrayMethods.push,已经找到了,就不会往下进行找了。
可以注意到,封装的这几个方法 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'
,都是涉及到数组内容会被改变的,那如果我要调用 arr.map 方法呢?还是刚刚讲的 原型链 机制,arrayMethods 没有 map 方法,就继续顺着 __proto__
往下找,然后找到 Array.prototype.map
。
不得不说,这个数组的扩展封装,可以学习到很多,赞赞赞 ~
上面讲的例子都是对一个数组内容的改变。细节的小伙伴会发现,如果我对整个数组进行赋值呢,如:arr = [4,5,6]
,拦截不到吧,是的。其实我只是把这个例子和上面第二点的例子拆分出来了。我们只需要对上面 observer
方法,进行这样一个判断,即
function observer(value) {
if (!(value !== null && typeof value === 'object')) {
return;
}
if (Array.isArray(value)) {
protoAugment(value, arrayMethods);
} else {
walk(value);
}
}
多级对象和数组的拦截概念其实很像,只是对象只需要逐级遍历封装拦截器,而数组需要用AOP的思想来封装。
4. 微任务(microtask)的妙用丨event loop
直接来一手例子:
var waiting = false;
function queue(val) {
console.log(val);
nextTick();
}
function nextTick() {
if (!waiting) {
waiting = true;
Promise.resolve().then(() => {
console.log('The queue is over, do something...');
});
}
}
queue(1);
queue(2);
queue(3);
// 1
// 2
// 3
// The queue is over, do something...
解释:主程序方法执行完毕之后,才会执行 promise 微任务。这也可以解释,为什么 Vue 更新动作是异步的【即:我们没办法立即操作 dom 】,因为这样做可以提高渲染性能,后面会具体讲这块。
5. 闭包的妙用
这里也直接来一手例子,个人认为这个闭包用法是成就了依赖收集的关键 ~
var id = 0;
var Dep = function () {
this.id = id++;
};
Dep.prototype.notify = function notify() {
console.log('id :>> ', this.id, ',通知依赖我的观察者们');
};
function defineReactive(obj, key) {
var dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {},
set() {
dep.notify();
}
});
}
var obj = { str1: 'hello1', str2: 'hello2' };
defineReactive(obj, 'str1');
defineReactive(obj, 'str2');
obj.str1 = 'hello1-change';
obj.str2 = 'hello2-change';
// id :>> 0 ,通知依赖我的观察者们
// id :>> 1 ,通知依赖我的观察者们
这也是第一点讲到的关联 dep 对象,现在每个属性都可以访问到词法作用域的属于自己的 dep 对象,这就是闭包。
6. with 改变作用域
这里只是模拟一下 Vue 的渲染函数
function render() {
with (this) {
return `<div>${message}</div>`;
}
}
var data = { message: 'hello~' };
render.call(data);
// <div>hello~</div>
这就是我们平时在 <template>
中不用写 {{ this.message }}
的原因,而是如:
<template>
<div> {{ message }} </<div>
</template>
上面这 6 点是个人觉得有学习到东西的地方,当然要深入理解依赖收集,我们需要走一遍流程。如果你当前在电脑前,我会告诉你需要打第几行的断点,让我们一起读源码吧,go go go ~
深入源码
tip:为了阅读质量,我会把一些相对与流程无关的代码省略掉,代码中类似「✅ :123」,表示需要打的断点,谷歌浏览器上开启调试 ctrl + o
,输入 :123 即可跳转至 123 行。
本地新建 index.html,引入 Vue 包,打开浏览器浏览
<body>
<div id="app">
<div>{{message }}</div>
<button @click="handleClick">change</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js"></script>
<script>
var app = new Vue({
el: '#app',
data: {
message: 'hello world'
},
methods: {
handleClick() {
this.message = 'hello world 2';
}
}
});
</script>
</body>
断点 ✅ :4700 initData方法
顾名思义,初始化我们写的 data 数据并做一些操作,在这个方法里有两个方法值得我们关注,proxy(vm, "_data", key);
与 observe(data, true);
。
function initData (vm) {
var data = vm.$options.data;
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {};
var keys = Object.keys(data);
var i = keys.length;
while (i--) {
var key = keys[i];
✅ :4734 proxy(vm, "_data", key);
}
✅ :4738 observe(data, true);
}
tip:在遇到方法的时候,我们用步入的方式可以快速定位到方法,如图:
步入到 proxy 方法
function proxy (target, sourceKey, key) {
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key]
};
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val;
};
Object.defineProperty(target, key, sharedPropertyDefinition);
✅ :4633 }
分析:这个方法是在 while 里的,这里循环遍历了我们写在 data 上的对象。当前 target = vm ,key = message,走到这个 4633 断点,控制台打印 target,如图:
上面我们提到了一个 with
的例子:Vue会进行 render.call(vm)
。这样子我们就会触发到 message 的 get 方法,这是一个入口,后续会做一系列的操作。
步入到 observe 方法
function observe (value, asRootData) {
if (!isObject(value)) {
return
}
var ob;
✅ :4633 ob = new Observer(value);
}
分析:可以理解成这个方法开始正在对 data 上的 可观察数据 进行观察的一些提前准备,如:往属性上附加 get/set 拦截器,然后分别在 get/set 里做点什么...
步入到 new Observer [可观测类]
这是第一个核心类,接下来我们还会分别讲到其他的两个类,每个类都是核心
var Observer = function Observer (value) {
this.dep = new Dep();
if (Array.isArray(value)) {
protoAugment(value, arrayMethods);
} else {
✅ :935 this.walk(value);
}
};
分析:这里有个数组 or 对象的判断,对于数组拦截我们在上面已经有讲过了,我们现在关注 walk 方法。
步入到 walk 方法
Observer.prototype.walk = function walk (obj) {
var keys = Object.keys(obj);
for (var i = 0; i < keys.length; i++) {
✅ :947 defineReactive$$1(obj, keys[i]);
}
};
继续步入到 defineReactive$$1 方法
function defineReactive$$1 (
obj, // obj -> data
key, // key -> 'message'
val
) {
✅ :1021 var dep = new Dep();
val = obj[key];
var childOb = observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
var value = val;
✅ :1041 if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
if (Array.isArray(value)) {
dependArray(value);
}
}
}
return value
},
set: function reactiveSetter (newVal) {
var value = val;
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
val = newVal;
childOb = observe(newVal);
✅ :1070 dep.notify();
}
});
}
分析:这个方法可以说是依赖收集中的核心,通过 get 方法添加依赖,通过 set 方法通知观察者。我们上面讲到的 proxy 方法,可以把它当做第一层拦截器,当我们触发一级拦截器之后,就会到二级拦截器 defineReactive$$1 里定义的 get/set 方法。
new Dep() [观察目标类] 这个是第二个核心类。
还记得我们在上面说过这个方法是一个 “闭包” 吗?是的,在当前方法内部 Object.defineProperty(obj, key, {
以上的所有变量/方法,是各个属性各自独立拥有的。
至此,我们对于 data 上属性的 get/set 封装 就讲完了 。
如何对数据进行依赖收集?
断点 ✅ :4074
updateComponent = function () {
✅ :4067 vm._update(vm._render(), hydrating);
};
✅ :4074 new Watcher(vm, updateComponent);
分析:Watcher类,这个是第三个核心类,观察者类。和上面说的 Observer[可观擦类]、Dep[观察目标类],总共三个。这个代码片段是在 mounted 钩子之前调用的,也就是我们之前对 data 数据先进行了 get/set 封装之后,就要开始进行 render 了,在 render 之前,需要创建 render 观察者,为了方便我们这里叫它 renderWatcher
。除了 renderWatcher
,我们还有 computedWatcher
和 watchWatcher
,这两个分别是 计算属性 和 侦听器 观察者,在 Vue 中主要是这三个类型的观察者。
步入到 new Watcher [观察者类]
var Watcher = function Watcher (
vm,
expOrFn
) {
this.getter = expOrFn;
this.deps = [];
this.newDeps = [];
this.depIds = new Set();
this.newDepIds = new Set();
✅ :4467 this.get();
};
分析:
- deps:缓存每次执行观察者函数时所用到的dep所有实例。
- depIds:缓存每次执行观察者函数时所用到的dep所有实例 id,用于判断。
- newDeps:存储本次执行观察者函数时所用到的dep所有实例。
- newDepIds:存储本次执行观察者函数时所用到的dep所有实例 id,用于判断。
步入到 get 方法
Watcher.prototype.get = function get () {
✅ :4474 pushTarget(this);
var vm = this.vm;
✅ :4478 this.getter.call(vm, vm);
✅ :4491 popTarget();
✅ :4492 this.cleanupDeps();
};
分析【这段分析比较详细】:pushTarget 和 popTarget 是一对方法,分别用来记录当前的观察者,和剔除当前观察者
Dep.target = null;
var targetStack = [];
function pushTarget (target) {
targetStack.push(target);
Dep.target = target;
}
function popTarget () {
targetStack.pop();
Dep.target = targetStack[targetStack.length - 1];
}
Dep.target 为全局唯一的,因为在一个时刻内,就只会有一个观察者函数在执行,把当前的 观察者实例 赋值给 Dep.target, 后续只要访问 Dep.target 就能知道当前的观察者是谁了。
我们继续步入 this.getter.call(vm, vm)
,【以下这几个步入我们就简单过一下】
updateComponent = function () {
✅ :4067 vm._update(vm._render(), hydrating);
};
步入 vm._update(vm._render(), hydrating)
Vue.prototype._render = function () {
✅ :3551 vnode = render.call(vm._renderProxy, vm.$createElement);
};
步入 render.call(vm._renderProxy, vm.$createElement)
,在谷歌会新打开一个 tab 用来执行下面这个函数
(function anonymous(
) {
with(this){return _c('div',{attrs:{"id":"app"}},[_c('div',[_v(_s(message))]),_v(" "),_c('button',{on:{"click":handleClick}},[_v("change")])])}
})
关键部分来了,这个是 Vue 的渲染函数。我们现在只要关注,它这里是会读取到 this.message 的,所以会触发 message 的 get 方法,也就是说当前观察者 renderWatcher
依赖了 message ,所以就会开始对它进行 “收集”。
谷歌浏览器器,直接点击下一步「 ||> 」,
我们就可以看到光标跳到了 defineReactive$$1
方法内部我们的 get 方法,开始进行“依赖收集” 了
get: function reactiveGetter () {
var value = val;
✅ :1041 if (Dep.target) {
dep.depend();
if (childOb) {
childOb.dep.depend();
if (Array.isArray(value)) {
dependArray(value);
}
}
}
return value
},
当前的 Dep.target 是有值的,所以执行 dep.depend 开始进行依赖,
步入 dep.depend
Dep.protJavaScriptotype.depend = function depend () {
if (Dep.target) {
✅ :731 Dep.target.addDep(this);
}
};
步入 Dep.target.addDep(this)
Watcher.prototype.addDep = function addDep (dep) {
var id = dep.id;
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id);
this.newDeps.push(dep);
if (!this.depIds.has(id)) {
dep.addSub(this);
}
}
✅ :4059 };
dep.addSub(this)
把当前的 watcher 实例 push 到 subs 数组,并且判断如果当前 观察者 被 观察目标 添加到 subs 数组里,就不会继续添加,过滤重复数据。
走到这个 4059 断点,控制台打印 dep,如:
dep = {
id:3,
subs:[
renderWatcher 实例
]
}
跳出继续往下走会调用 4491 popTarget()
,剔除当前 观察者。
接着步入 this.cleanupDeps()
Watcher.prototype.cleanupDeps = function cleanupDeps () {
var i = this.deps.length;
while (i--) {
var dep = this.deps[i];
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this);
}
}
var tmp = this.depIds;
this.depIds = this.newDepIds;
this.newDepIds = tmp;
this.newDepIds.clear();
tmp = this.deps;
this.deps = this.newDeps;
this.newDeps = tmp;
this.newDeps.length = 0;
};
这里把 this.deps = this.newDeps
,缓存到 deps 里,然后清空newDeps,来做下一次的收集。
至此,我们就完成了一个 依赖收集 ~
更新依赖数据如何 notify 观察者做出 update ?
官网:只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。
当用户点击change按钮
this.message = 'hello world 2';
光标自动跳转至 message 对应的 set 方法,执行 dep.notify() 进行通知观察者进行 update 动作
Dep.prototype.notify = function notify () {
for (var i = 0, l = subs.length; i < l; i++) {
✅ :745 subs[i].update();
}
};
步入 subs[i].update()
Watcher.prototype.update = function update () {
✅ :4543 queueWatcher(this);
};
步入 queueWatcher()
function queueWatcher (watcher) {
var id = watcher.id;
if (has[id] == null) {
has[id] = true;
if (!flushing) {
queue.push(watcher);
} else {
var i = queue.length - 1;
while (i > index && queue[i].id > watcher.id) {
i--;
}
queue.splice(i + 1, 0, watcher);
}
if (!waiting) {
waiting = true;
✅ :4403 nextTick(flushSchedulerQueue);
}
}
}
flushSchedulerQueue 方法
function flushSchedulerQueue () {
flushing = true;
var watcher, id;
queue.sort(function (a, b) { return a.id - b.id; });
for (index = 0; index < queue.length; index++) {
watcher = queue[index];
id = watcher.id;
has[id] = null;
✅ :4311 watcher.run();
}
}
分析[结合上面 queueWatcher
和 flushSchedulerQueue
两个方法]:
flushSchedulerQueue
方法:
queue.sort
需要排序是原因:
确保 watcher 的更新顺序与它们被创建的顺序一致。
- 对于父子组件来说,组件的创建顺序是父组件先被创建,然后子组件再被创建,所以父组件的renderWatcher的id是小于子组件的。
- 对于用户自定义watcher【watchWatcher】和 renderWatcher,用户自定义watcher是先于组件的renderWatcher被创建的。
- 如果子组件在父组件的监视程序运行期间被销毁,则会跳过子组件的watcher。
queueWatcher
方法:
- 这里进行了 watcher id 的重复判断,因为在一个
renderWatch
中可能会依赖多个观察目标,当我们同时改变多个依赖的值 ,经过判断 watcher.id 一样就不用把两次更新 push 到 队列,避免渲染性能消耗,如:
this.message1 = 'hello world 1';
this.message2 = 'hello world 2';
// 更多...
或 循环改变同一个依赖
for (let i = 0; i < 10; i++) {
this.message++;
}
- flushing 表示 queue 队列的更新状态,
flushing=true
代表队列正在更新中。
这里的 else 分支,主要是判断一种边界情况,i--
,从后往前遍历,其实目的是看刚进入的这个 watcher 在不在当前更新队列中。注意这里的 index
是来自 flushSchedulerQueue
方法内部定义的,是全局的。
我们可以看到跳出 while 的条件为:
queue[i].id === watcher.id
我们可以这样理解,当前在更新一个 id 为 3 的 watcher,然后又进来了一个 watcher,id 也为3。相当于需要重新更新一次 id 为 3 的 watcher,这样才能获取到最新值保证视图渲染正确。用代码解释如:
// ...
<div>{{ message }}</div>
// ...
new Vue({
el: '#app',
data: {
message: 'hello world'
},
watch: {
message() {
this.message = 'hello world 3';
}
},
methods: {
handleClick() {
this.message = 'hello world 2';
}
}
});
点击按钮更新 message 之后,又用 watch 监听其变化,然后在内部再对 message 进行更新,我们试着读一下这段代码的更新流程。首先,用户自定义watcher【watchWatcher】是先于 renderWatcher 被创建的,所以我们在更新 message 的时候,会先执行 watch ,触发到内部方法又更新了一次 message,为了保证视图渲染正确,我们需要在执行一次这个 watcher 的 update。
queue[i].id < watcher.id
分析:更新队列中有 id 为1,2,5 三个 watcher,当前正在更新id为 2 的watcher,当 queueWatcher 被调用并传进来一个 id 为 3 的watcher,于是就将这个 watcher 放到 2 的后面,确保 watcher 的更新顺序与它们被创建的顺序一致。
我们都知道,flushSchedulerQueue
方法是一个微任务。在对queue操作之后,主程序方法执行完毕之后,开始执行微任务,进行 queue 的调度更新,watcher.run()
至此,我们就完成了当观察目标改变时通知观察者更新的动作。
总结
以上举的例子是一个简单 renderWatcher
的一个流程闭环,依赖收集 到 通知更新。Vue 有renderWatcher
【视图观察者】,computedWatcher
【计算属性观察者】 和 watchWatcher
【侦听器观察者】,主要这三个类型的观察者。
主要的三个类 Dep【观察目标类】,Observe【可观测类】,Watcher【观察者类】。
我们可以理解,在依赖被改变的时候通知观察者的一过程,一切都是为了视图渲染,在这过程中会进行一些性能优化 / 处理一些边界情况,最终保证视图渲染的完整性。
个人觉得源码有点晦涩难懂,但还是得自己多过几遍才能熟悉。这边还是建议亲自阅读几遍源码,看一些他人的总结还是会有点模糊,所以本篇文章提供了 断点 参考。帮助小伙伴快速定位源码比较精髓的位置。
了解源码的运作,也可以让我们更加的知道,我们需要怎么去调用框架提供的 api 会更加优化。
后话
Vue3 已经出来了,我们看完 Vue2 就可以对比看看 Vue3 的更强大之处了,
这边就不再举例 computedWatcher
和 watchWatcher
了,小伙伴们可以动手 debug 看看 ~ 。
可以从页面的 initState 方法作为入口:
function initState (vm) {
vm._watchers = [];
var opts = vm.$options;
if (opts.props) { initProps(vm, opts.props); }
if (opts.methods) { initMethods(vm, opts.methods); }
if (opts.data) {
initData(vm);
} else {
observe(vm._data = {}, true /* asRootData */);
}
✅ :4645 if (opts.computed) { initComputed(vm, opts.computed); }
if (opts.watch && opts.watch !== nativeWatch) {
✅ :4647 initWatch(vm, opts.watch);
}
}
感兴趣的小伙伴也可以 debug 看 computed 这种场景
computed: {
c1() {
return this.c2 + 'xxx';
},
c2() {
return this.message + 'xxx';
}
}
computed 是 “lazy” 的,它不参与 queue 的更新,而是如果在模板上有用到 computed 属性,才会去进行获取计算后的值。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。