软件开发里本没有服务端,分的细了就有了服务端。做为一个软件开发者,每个人都可以是全栈。看到“服务端全栈”这个词,不知道屏幕前的你现在脑子里想到的是什么问题。
- 老板:我们团队的服务端可以去写前端么?会不会搞出很多故障?能不能缩短开发时间?能不能给我节省成本?
- 前端:你都能写前端了,那还要我干嘛?
- 服务端:我有必要学前端么?写前端对我职业生涯有啥好处?学到啥程度可以写前端需求,发布前端的应用?
我第一次知道这个词的时候,脑子里是一片空白的状态:老板把我叫到茶水间,了解了一下前端经验、排期情况,选了我去做“服务端全栈化”。具体就是有个项目前端缺人手,项目中已经有个前端大佬,让我去打打辅助。
当时的我只在flask项目写过简单的html,三大前端框架都不会,满心欢喜的准备去项目室抱大腿学前端。等我到了项目室发现,带我的前端大佬是日理万机的前端大团队TL,我也不太好意思让大佬陪着我写代码,我就变成了项目里唯一一个前端开发,大家都叫我团队的希(瓶)望(颈)。
从那天开始,我白天强装镇定在项目室写前端,深夜疯狂补课、学习前端知识,解决白天遇到的问题。过程中好在前端TL很给力,帮忙找了很多资源解决我开发中遇到的问题,就这样磕磕绊绊持续了一个多月,项目终于上线了。这也成了我的全栈化初体验。
在此后的一年中,我在没有耽误服务端成长的情况下,从一个需要前端协助的全栈开发,成长为了独当一面、可以牵头整个模块级的前后端系分设计、辅导其他服务端同学进行前端开发、把控前端项目质量、上线前端项目的全栈化同学。
作为团队的全栈化标杆同学,和我们团队唯一的前端同学一起,在老板的鼎力支持下,走出了一套可复制可迁移的服务端团队全栈化道路,团队里年轻的服务端开发同学都具一定的全栈能力,团队的前端资源不再成为瓶颈。
在我和我们团队其他同学的全栈化实践中,我注意到每个阶段都有一些共性的痛点和问题,也有一些自己的解决思路和经验,希望分享出来让后人可以更轻松加入全栈化大家庭,走的更快更稳。
第一步:从入门到不放弃——前端基础学习
在入门阶段,我们需要解决以下几个问题:
- 前端知识去哪学,怎么学?学到啥程度可以动手?看完我以为我会了,一动手写憋不出两行代码。
- 语言问题、开源框架的问题可以问chatGPT,内部框架、工具的问题没有头绪。
- 简单的画页面和逻辑终于可以写了,一遇到环境问题或者疑难杂症就只能求助或绕过。
Tip1 系统性快速学习,了解技术版图,开发过程中扣细节
不知道大家在大学的考试周,有没有过“一夜一门课,两周一学期”的体验。我作为一个临时抱佛脚选手,对这一点深有体会。这种学习习惯本身并不值得提倡,但是其中的一些学习技巧可以提炼出来迁移运用。
工作中的学习,很少能够有让人完全准备好再上的机会,往往也是类似考试周的这种系统性快速学习+突击性详细学习的组合。系统性的快速学习可以在整个知识体系中,画出一张地图;突击性的学习能够让地图上具体的一小块更清晰。有了地图和不断的突击,就可以更快把知识连成面,形成自己的知识体系。
以钉钉的前端知识体系为例,我们团队的前端同学已经给大家梳理了一份前端知识点大图,这个大图解决了从0到1学什么的问题,它就像前文里说的地图,有了这个地图,就有了前端知识海洋里的导航,要做的就是探索和完善这个地图。
关于看视频还是看书学习,不同人的习惯各不相同,我建议选择适合自己的就好,相关的资料也非常好找,不论是阿里内部的奇点学堂,还是外部的B站、油管、以及各种知识付费App,都有很多优秀的学习材料。
比学习知识点更重要的是实践。在全栈化初期,我的前端能力之所以能快速成长,离不开前文提到的那段项目里白天写bug,晚上边学边修bug的经历。例如学完React的hook,立刻用hook重写一下自己之前的Class组件;学完高阶函数,立刻用高阶函数去重构一段适配器逻辑。我并不鼓励炫技式的使用一些组件或特性,但是如果一个新知识点可以让我们的代码从中受益,我一定会去尝试。
Tip2 不要放过任何一个疑难杂症或者别扭的地方
在入门阶段,我经常出现自己写了一段烂代码而不自知的情况,不过很快我有了感知这类烂代码的技巧:当一段逻辑,自己都觉得写的不顺手或别扭的时候,往往意味着有更好的解决方案。这时应该记下来,尽快查找、请教,寻取更好的实现方式,并对知识点查漏补缺。
下面这段代码我自己第一次用React实现一个带有筛选器的、支持分页加载更多的表格。直观看,就是有很多个state,并且state之间不是正交的。
const TableBizComponent: React.FC<ITableBizComponentProps> = (props) => {
const {dataId, corpId, yearMonth} = props; // 年月筛选和数据id从props传入
const [pageNo, setPageNo] = useState(1); // 页号初始化
const [selectedFilterId, setSelectedFilterId] = useState(null); // 筛选器选中id
const [filterData, setFilterData] = useState(null); // 筛选器数据
const [columns, setColumns] = useState([]); // table表头
const [data, setData] = useState([]); // table 全部数据行
const [hasMore, setHasMore] = useState(true); // 是否更多 初始化
const [needUpdate, setNeedUpdate] = useState(false); // 是否需要调用loadMore
const [isLoading, setIsLoading] = useState(null); // 页面级加载中
const [isPageLoading, setIsPageLoading] = useState(false); // 分页加载中
// 初始化数据
useEffect(() => {
// ...
}, []);
// 页面切换
useEffect(() => {
// ...
})
}
没有React基础,我形象的解释一下上述代码的问题:现在有10个开关,控制一个页面的渲染,这些开关并不是独立的,可能开了A开关,C开关也跟着开了;关闭了B开关,D,E开关也动了。这就导致一个简单的用户交互,我需要手忙脚乱的操控这些开关,让他们微妙的配合,达到正确渲染页面的目的。
虽然当时这段代码没有bug,但是调试和日后维护都是一场灾难。所以我就去找了不少讲state的文章,了解到了“单一数据源”、“最小状态”等这些概念,也找到了更多成熟的逻辑封装库,最终改了一版代码变成这样。
const TableBizComponent: React.FC<ITableBizComponentProps> = (props) => {
const {dataId, corpId, yearMonth} = props; // 年月筛选和数据id从props传入
const [selectedFilterId, setSelectedFilterId] = useState(null); // 筛选器选中id
const {data, loading, loadMore, noMore} = useInfiniteScroll( // 分页查
(currentData: IData): Promise<IData> => {
return new Promise((resolve, reject) => {
const pageNo = Math.ceil((currentData?.list?.length ||0) / PAGE_SIZE);
// ...
})});
// ...
}
修改后的代码去掉了不独立的state,引入了自定义hook对分页查询的通用逻辑进行封装,而这种通用封装逻辑,已经有一些经过工业界验证的开源库可以使用,最终代码变清晰了,后续的维护难度也大大降低。
除了上面这个例子,在项目中我还被移动端缩放适配的问题困扰过,尽管绕了很多弯路最终发现只是一行配置没有加上,但排查问题的过程帮助我把前端的尺寸方案,webpack,浏览器的视口、根元素、物理/独立像素比等这些基础知识都补齐了一遍。
在全栈化成长的初期,独立解决3-5个这类疑难杂症式的绊脚石,对自己的能力和信心的提升都会有很大的帮助。
Tip3 内部开发环境、工具相关的问题多问多总结,维护自己的QA
在全栈化开发过程中,遇到语言、开源框架相关的问题时,往往可以用好搜索引擎和chatgpt寻找解决方案。而不同公司、BU、团队往往会有些内部开发环境、开发工具以及独有的内部依赖,这类依赖或多或少会存在文档没有及时更新或丢失,找不到维护人等问题。
这些依赖出现问题时,如果你在前端团队里,往往都是通过老带新,口口相传,或者一些内部流传的文档解决的。但是作为一个要做全栈化的服务端同学,身边没有一群踩过坑的老司机,遇上这类问题就抓瞎了。
这个阶段的问题,有时可能可以看到内部源码,还可以自己尝试解决;有时靠自己会花大量时间走大量弯路最终也很难找到问题原因。这种时候作为全栈化同学,还是需要有一个部门里的前端大佬报下大腿,毕竟站在巨人的肩膀上才能看的更高嘛。
以我自己为例,当时我厚着脸皮加到前端团队的知识库里,每次遇到这类问题时,我都会去知识库中看看有没有前人的沉淀可以解决,同时也在自己的知识库里分类做好沉淀和索引。如果知识库的内容还是不能解决问题,就立刻去寻求前端同学的帮助。当我在做第一个前端项目的时候,是真的什么都不会什么都不懂,我实在没辙就把前端TL的组织架构拉了出来,对着组织架构轮流找下面几个花名眼熟的前端同学轮流问问题。
虽然有些工具缺少持续更新的文档,但是大部分问题都是可以通过多问多找的方式,最终找到维护人或者了解这个工具历史的人,给到解决方案或者解决思路。
作为一个服务端的同学,一些服务端的问题自己解决不了时,可能还会因为有些包袱不好意思请教别人;但作为一个服务端同学写前端时,在战略上更要拿出“我不会我有理”的态势;在战术上虚心请教,比如提问时最好简洁描述问题、带上自己已经查找了哪些相关资料的背景信息;大佬帮忙解决完问题后,尽量想想自己可以怎么给大佬提供价值,常见的手段可以是将问题和解决方案总结整理好,提供给大佬维护到QA。
Tip4 全栈化学习里的二八定律
集中学习、集中实践对于掌握一项技能来说往往是最高效的。
看到这里的开发同学或者TL,我真诚的建议如果想在组里推进全栈化的话,一定要在初期争取让全栈化同学去完成完整的有一定规模和难度的前端需求。
在我自己的全栈化成长过程中,我在前20%的成长周期里摸索了80%的前端常用技术,快速形成了战斗力,能够在前端需求汇总成为独当一面的多面手。在之后的成长周期中,才是不断遇到一些疑难杂症,并在挑战这些疑难杂症的过程中继续成长,向能够把控前后端整体需求的目标看齐。
第二步:术与道——前端思维学习
对于一个服务端同学,一般走完了第一个阶段,拿到一个前端需求都能够实现。但是代码距离优秀的前端工程代码还有着巨大的进步空间,往往存在缺少抽象,实现比较笨拙的问题。套用马丁福勒的话说,代码没有前端的味道,甚至有点坏味道。
我认为造成这个阶段的问题的原因,不是服务端同学的代码能力不行,而是服务端同学读过的前端代码太少,输入不够、眼界不够开阔,缺少对于一些编程套路、编程范式的了解,导致功能都可以实现,但是不够健壮、不够简洁、对于需求变化不够友好。
我选了几个我们团队的服务端同学在全栈化过程中写下的前端代码片段,在重构前后的对比作为例子。这几个例子有一定的代表性,在重构前,它们没有语法错误,也基本能满足功能性的要求,但是重构后代码更加简洁、健壮,并且更符合前端的编程思路,能够有效的降低后续修改和维护的成本。
误区1 把服务端的编程习惯带入前端开发中
重构前:
const resultA = await rpc(request1);
const resultB = await rpc(request2);
if (ressultA !== null && resultB !== null) {
// process
}
对于服务端同学来说,这段代码简直不能再熟悉了,除了await这个关键字可能不认识以外,和服务端代码没有什么区别。
这段代码不能说它有问题,但是编程风格和前端强调的事件驱动、异步回调的风格不太一致。服务端同学,尤其是习惯了面向对象的服务端,写前端时经常会把自己同步编程的编程风格和面向对象的编程范式生搬硬套到前端代码中。这样的代码在前端看来就像方言,在语法和语言自身的功能上没有大的问题,但不是普通话,有一些理解成本和协同成本。
重构后的代码如下:
Promise.all([rpc(request1), rpc(request2)]
).then((results) => {
// process
}).catch((e) => {
// process exception
});
Promise模型并不是什么新东西,可能在常见服务端语言(Java、C++、Python等)中不太常见,但是在前端中,这个异步模型有着广泛的使用。使用 Promise 的好处包括但不限于,
1.异步控制:Promise 提供了一种灵活的方式来管理和控制异步操作。通过链式调用then()和catch()方法,可以更好地处理异步操作的成功和失败情况,以及链式执行多个异步操作。
2.并行执行:可以使用Promise.all()方法来并行执行多个异步操作,并在它们都完成后获取结果。
3.更好的错误处理:Promise可以使用catch()方法来捕获和处理异步操作的错误情况。
4.前端友好:在大部分前端都习惯使用Promise的环境下,使用Promise模型编写的代码,能降低理解成本。
值得一提的是,这里的异步和服务端常见的线程池异步处理有一定的区别。js在执行过程中其实是单线程的,它的异步由主线程和任务队列构成,在主线程空闲的时候,会遍历队列中的异步任务执行。
误区2 对弱类型语言不够敬畏
重构前:
fn1 = (alist) => {
if (alist !== null) {
this.props.myfunc(alist.length);
}
};
这段代码咋一看也没有问题,但是仔细一想,myfunc、length这些方法和属性都来的莫名其妙。
如果这段js代码服务端同学看完没啥体感的话,拿python举例大家可能更加亲切。python的很多开源仓库早期代码都经常被吐槽“从裤裆里掏出一个变量”,很难看出来这个变量是啥含义,什么时候加进去的,什么情况有值。弱类型语言如果没有类型系统约束,在小脚本中看不出差异,但是在大型工程或者需要长期维护的项目中,经常出现写时一时爽,维护火葬场的灾难。
前端中为了解决JavaScript弱类型语言的弱点,微软发布了TypeScript,ts作为js的超集,自带了一套非常强大的类型系统,但是为了兼容js的一些场景,留了any这样的超级类型,通过any可以绕过ts的类型检查。
在服务端开发中,我们可以依赖编译去避免变量、方法声明前使用的错误。而在前端学习中,一些同学可能又是从js开始学起,对于前端中的类型系统使用不够熟练,对于弱类型语言不够敬畏,导致了前端开发中出现了大量any绕过编译期检查,不判断直接使用属性等问题,给线上留下巨大隐患。
重构后代码如下:
fn1 = (alist: string[]) => {
this.props.myfunc && this.props.myfunc(alist?.length || 0);
};
这段代码的改动很简单:通过类型申明在编译期发现隐患;方法和属性使用前增加了检查,避免由于不符合预期的使用导致页面白屏;代码加入了更多兜底,上线后更加健壮、安全。
前端代码在业务逻辑上或许很多时候没有服务端那么复杂,大部分场景就是做一些数据适配和渲染的工作,让全栈化同学在起初上手的时候觉得so easy。但是前端代码藏有大量的细节容易被忽视,一个老道的前端和一个新手的代码的差异可能也就多了几个简单的“?”和判断,但是往往魔鬼藏在细节里,少了任何一个判断和申明,对应的都是线上的一个定时炸弹。
误区3 代码复用全靠粘贴,逻辑和渲染耦合严重
重复代码是一种代码的坏味道。带来的问题包括但不限于理解成本和修改成本的增加。
在服务端开发中,我们往往可以通过提炼函数、函数上移等手段对代码进行优化。在前端中,如果多个页面都有无限滚动、分页查询、防抖、滚动监听等这类逻辑,应该如何复用呢。如果两个前端模块,在样式和结构上有80%的相似,但是又不完全一样,要怎么处理呢?
这时很多前端新手就会祭出CV大法,复制粘贴稍作修改,需求就完成了。但是同时,这也是在给代码中注入新的坏味道。
当我遇到这个问题时,我的第一个想法是,复制粘贴稍作修改太不优雅了,一定有我不知道的知识点可以解决这个问题。通过我的搜索和学习,我发现前端的代码复用主要有两种,逻辑复用和渲染结构复用。
要想代码能够复用,就必须找到切面,把内聚的部分剥离,留下恰当的接口进行交互。
第一步要做的就是逻辑和渲染分离。具体到代码上,一个业务模块也可以拆分成MVC(model、view、control)三个部分,其中包含了请求数据、转换数据、组装数据、编写不同渲染结构的控制逻辑、生成渲染结构几个步骤。我们需要把生成渲染结构和前面的步骤分离,负责渲染的组件只负责画页面,页面中的数据都是占位符,通过外部组件传入,这就形成了我们自己定义的UI组件。我们在前端开发中会用到一些UI库,例如antDesign、bizChart等,这些库的形成就是逻辑和渲染分离思想的体现。
第二步,UI组件的复用。有了UI组件后,我们可以再看看工程中是否有其他语义和功能相似的渲染结构,可以求同存异,相同的部分沉淀到UI组件中,不同的地方通过增加扩展点等方式匹配。
第三步,逻辑的复用。上面提到的无限滚动、分页查询、防抖、滚动监听等逻辑有一定的特点,它们实际上是通用逻辑,在很多页面都会用到。同时页面渲染、交互和这些逻辑之间存在一定的关联关系,例如这些逻辑里面会维护状态、会调用页面的方法、页面需要调用这段逻辑当中实现的方法等。这时可以借助函数式编程的思想,把处理流程中不同的逻辑打包成方法,作为入参传进来,同时把外部都依赖到的方法,作为方法的出参返回出去。对于函数式组件,可以通过自定义hook来达到上述目的;对于类组件,可以通过高阶函数达到。
关于逻辑复用的部分,空讲可能没有什么体感,建议读者有空可以去读一读ahook[1]这个仓库的代码和demo,最好能在自己的前端项目中,把里面的自定义hook用起来,对于理解逻辑复用会有很大的帮助。在上一节的Tip2中,我为了优化自己的表格使用到的useInfiniteScroll便是出自于此。
经验与解决方案
前端思想的积累,肯定不是一朝一夕的事情。于我而言,持续积累的动机来源于对高质量代码的追求。在全栈化中不能只满足于实现需求,而需要想方设法提高自身代码的质量,对高质量的代码做到先模仿、再创造。
为了了解更多的工程范式,找到更多可以模仿的高质量代码,在度过了“存活都是问题”的阶段后,全栈化同学更要多读一些前端代码,找成熟且相对干净的前端工程进行参考。
作为一个服务端同学,在初入前端时,对前端的语法和生态会产生一些“culture shock”,我在这里简单总结了一些TS+React生态和Java+Spring生态的异同,包括在编程模型、设计模式、语法等方面的相同点和不同点,帮助大家迁移和理解。
第三步:守好安稳底线
全栈化的最终一步还是需要安稳上线,让服务端同学写前端代码能不能按时上线?敢不敢上线?都是巨大的考验。在全栈化过程中我们发现,由于全栈化同学的经验不足,在研发阶段可能出现排期过于乐观,上线阶段容易出现“不知道我不知道”的风险等问题。
对于这类问题,我们团队唯一一个前端同学在推进全栈化的过程中,也和全栈化的服务端同学一起踩了不少坑,总结了一些经验和教训。
排期过于乐观
首先是排期管理和对老板的预期管理,全栈化初期阶段,由于同学对前端技术栈不够熟悉,边学边做是常态,需要在时间上留足buffer,并且PM也需要做好相应的风险管理,遇到卡点需要及时找前端同学介入。我建议在一个同学开始做全栈化开发的前三个需求,排期按照正常前端排期/0.6的冗余进行安排,之后逐渐收敛到和前端正常排期一致。
“不知道我不知道”的风险
为了避免上线阶段出现”不知道我不知道“这种问题,我们建立了把关机制和工作难度进阶机制,对于一个从头开始做全栈化的服务端同学,会经历这样几个阶段:
- 专业前端做系分,全栈化同学做模块实现,专业前端做CR和上线;专业前端在系分编写、开发、CR、回归、发布节点把关。
- 专业前端辅导系分,全栈化同学完成需求实现,回归到上线的闭环;专业前端在系分编写、CR、回归节点把关。
- 全栈化同学独立完成需求系分、实现、回归、发布;专业前端在系分评审、CR把关。
- 全栈化同学完成前后端需求整体闭环,专业前端在系分评审、CR把关。
每个阶段做到没有大的纰漏,能够独立完成后,可以挑战下一个阶段的工作。经过几轮需求的学习,就能循序渐进的知道从需求系分到发布整个过程中需要注意的问题。此外,我们团队建立了一套全栈化认证等级,确保每个阶段的同学都在做有一定挑战性但是风险可控的全栈化工作,做好这个等级的工作后,有明确的晋级路径,去完成下一个等级更加有挑战性的工作。
这套机制具体可以参考我们团队前端同学的文章钉钉全栈化实践总结-前端篇(前端初学者落地指南)。
第四步:全栈不止是前端开发
回想我在进行全栈化之初,要解决的是团队内前端资源紧张的问题。但是从全栈化中获得的不只是前端开发的能力。一个掌握了前端开发能力的服务端同学,在业务开发中就是打通了任督二脉,打开了全新的世界。
一杆到底解决问题的能力
我们小组当前做的业务是钉钉智能差旅,需要把市面上各类出行服务商和商旅服务商的机票、酒店、火车票、用车的服务能力接入到钉钉当中。对接的过程涉及到服务端的接口交互和前端的页面交互。
以往这类对接需要拉上两方的前端、后端、产品、PM等一系列角色到一个群里,进行对接和联调,过程中如果涉及到其他一方能力提供方的问题,也需要把相关方拉到群里沟通。整个协同链条比较长,中间经常会出现“无能为力的传话筒”。在具有解决前端问题的能力之前,遇到前端问题时,我就是那个可怜的传话筒。
例如在和美团的对接过程中,遇到了在美团的钉钉小程序的订单下单页面拉起支付宝收银台闪退的问题。这时候需要懂钉钉的支付jsapi的前端,懂支付接口的服务端,会定位容器层问题的同学一起进来排查。整个排查过程存在着大量的协同成本。
这时候,具有全栈化能力的服务端同学,一个人就能符合上面所说的这些要求,可以整体收口整个对接工作。我通过和美团侧前端同学的沟通,构造出最小可复现问题的demo,并和容器同学一同定位了闪退原因,最终解决了问题,减少了大量沟通、协同的成本。
这种一杆到底解决问题后的酣畅淋漓,是我掌握全栈化能力后收获的意外之喜。
前后端全局视角
差旅业务沉淀了组织大量的出行数据,分析这些数据对帮助管理人员决策有着一定的帮助。在智能差旅的报表需求中,我牵头了整个报表模块的前后端系分。
对客报表的需求本身不算复杂,它的特点是图表数量多,每个图表的业务流程比较相近,因此我们希望一套前后端方案满足所有不同类型的图表的取数、数据处理、数据渲染。具体而言,我们的方案是用一套schema定义图表,服务端提供一个统一的接口,根据schema进行数据获取和组装;前端根据schema渲染特定的图表类型并填充数据。
这套方案挺好,美中不足的问题有二:前端和服务端分别需要去理解一遍整套schema,并且图表中的字段多,前后端的每一个小调整都需要来回对焦,增加了协同成本;每种类型的图表都是一个前端组件,前端的工作量比服务端大,在前端资源紧张的情况下,前后端排期不匹配的问题加剧。
我们对此的解决方案是:
- 我作为一个全栈化同学负责整体前后端的系分,并在开发中主要承担前后端schema解析相关的部分,对其他参与开发的同学尽可能屏蔽schema细节;
- 一部分全栈化同学分到具体的图表组件的前端开发工作,并行的完成不同图表业务组件的开发;
- 团队里唯一的前端同学负责把控前端性能问题和稳定性相关的难点问题;
基于上述方案,我们通过团队的全栈化,解决了前后端排期不匹配和协同成本高的问题。在牵头前后端的整体系分过程中,无论是可行性还是工作量,我都能够较为准确的设计和评估,这都是得益于之前积累的全栈化的能力。
全栈化的不止是前后端
作为一个业务开发同学,相较于后端研发这个职位定义,我更喜欢亚马逊SDE的职位表述,Software Develop Engineer,业界戏称Someone Do Everything。
当初因为前端人力的紧缺,我们团队迈上了服务端全栈化的道路,并在探索后发现,把服务端开发人力和前端的开发人力放在一起做资源规划,让服务端完成日常前端开发中80%以上的工作完全没有问题。不仅资源瓶颈问题得到的了解决,团队里开发同学也拓宽了能力边界。
实际在业务推进过程中,我们可能会遇到各种资源的瓶颈,可能是数据分析的排期不匹配,可能是算法支持的排期不匹配等等。对于开发同学而言,不妨结合自己的兴趣点想一想能否向前一步,成为团队的多面手,帮助团队解决资源瓶颈,发挥自己不可替代的价值。
作者|鞍点
点击立即免费试用云产品 开启云上实践之旅!
本文为阿里云原创内容,未经允许不得转载。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。