本文来自OPPO互联网技术团队,转载请注名作者。同时欢迎关注我们的公众号:OPPO_tech,与你分享OPPO前沿互联网技术及活动。
微前端是指存在于浏览器中的微服务,通常由许多组件组成,并使用类似于 React、Vue 和 Angular 等框架来渲染组件,每个微前端可以由不同的团队进行管理,并可以自主选择框架。
每个微前端都拥有独立的 git 仓库、package.json 和构建工具配置。因此,可以拆分一些巨石应用为多个独立的模块再组合起来,应用间独立维护及上线,互不干扰。
本文通过一些精简代码的方式介绍微前端框架qiankun
的原理及OPPO云
在这上面的一些实践。
注:本文默认读者使用过qiankun
框架,且文中使用的qiankun
版本为:2.0.9
。
1. qiankun 的前身 single-spa
qiankun
是一个基于 single-spa
的微前端实现库,在qiankun
还未诞生前,用户通常使用single-spa
来解决微前端的问题,所以我们先来了解single-spa
。
我们先来上一个例子,并逐步分析每一步发生了什么。
import { registerApplication, start } from "single-spa";
registerApplication(
"foo",
() => System.import("foo"),
(location) => location.pathname.startsWith("foo")
);
registerApplication({
name: "bar",
loadingFn: () => import("bar.js"),
activityFn: (location) => location.pathname.startsWith("bar"),
});
start();
- appName: string 应用的名字将会在 single-spa 中注册和引用, 并在开发工具中标记
- loadingFn: () => 必须是一个加载函数,返回一个应用或者一个 Promise
- activityFn: (location) => boolean 判断当前应用是否活跃的方法
- customProps?: Object 可选的传递自定义参数
1.1 元数据处理
首先,single-spa
会对上述数据进行标准化处理,并添加上状态
,最终转化为一个元数据数组,例如上述数据会被转为:
[{
name: 'foo',
loadApp: () => System.import('foo'),
activeWhen: location => location.pathname.startsWith('foo'),
customProps: {},
status: 'NOT_LOADED'
},{
name: 'bar',
loadApp: () => import('bar.js'),
activeWhen: location => location.pathname.startsWith('bar')
customProps: {},
status: 'NOT_LOADED'
}]
1.2 路由劫持
single-spa
内部会对浏览器的路由进行劫持,所有的路由方法
和路由事件
都确保先进入single-spa
进行统一调度。
// We will trigger an app change for any routing events.
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
// Monkeypatch addEventListener so that we can ensure correct timing
const originalAddEventListener = window.addEventListener;
window.addEventListener = function(eventName, fn) {
if (typeof fn === "function") {
if (
["hashchange", "popstate"].indexOf(eventName) >= 0 &&
!find(capturedEventListeners[eventName], (listener) => listener === fn)
) {
capturedEventListeners[eventName].push(fn);
return;
}
}
return originalAddEventListener.apply(this, arguments);
};
function patchedUpdateState(updateState, methodName) {
return function() {
const urlBefore = window.location.href;
const result = updateState.apply(this, arguments);
const urlAfter = window.location.href;
if (!urlRerouteOnly || urlBefore !== urlAfter) {
urlReroute(createPopStateEvent(window.history.state, methodName));
}
};
}
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
以上是劫持代码的精简版,可以看到,所有的劫持都指向了一个出口函数urlReroute
。
1.3 urlReroute 统一处理函数
每次路由变化,都进入一个相同的函数进行处理:
let appChangeUnderway = false,
peopleWaitingOnAppChange = [];
export async function reroute(pendingPromises = [], eventArguments) {
// 根据不同的条件把应用分到不同的待处理数组里
const {
appsToUnload,
appsToUnmount,
appsToLoad,
appsToMount,
} = getAppChanges();
// 如果在变更进行中还进行了新的路由跳转,则进入一个队列中排队,
if (appChangeUnderway) {
return new Promise((resolve, reject) => {
peopleWaitingOnAppChange.push({ resolve, reject, eventArguments });
});
}
// 标记此次变更正在执行中,
appChangeUnderway = true;
await Promise.all(appsToUnmount.map(toUnmountPromise)); // 待卸载的应用先执行unmount
await Promise.all(appsToUnload.map(toUnloadPromise)); // 待销毁的应用先销毁
await Promise.all(appsToLoad.map(toLoadPromise)); // 待加载的应用先执行load
await Promise.all(appsToBootstrap.map(toBootstrapPromise)); // 待bootstrap的应用执行bootstrap
await Promise.all(appsMount.map(toMountPromise)); // 待挂载的应用执行mount
appChangeUnderway = false;
// 如果排队的队列中还有路由变更,则进行新的一轮reroute循环
reroute(peopleWaitingOnAppChange);
}
接下来看看分组函数在做什么。
1.4 getAppChanges 应用分组
每次路由变更都先根据应用的activeRule
规则把应用分组。
export function getAppChanges() {
const appsToUnload = [],
appsToUnmount = [],
appsToLoad = [],
appsToMount = [];
apps.forEach((app) => {
const appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) {
case LOAD_ERROR:
case NOT_LOADED:
if (appShouldBeActive) appsToLoad.push(app);
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
case MOUNTED:
if (!appShouldBeActive) appsToUnmount.push(app);
}
});
return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
1.5 关于状态字段的枚举
single-spa
对应用划分了一下的状态
export const NOT_LOADED = "NOT_LOADED"; // 还未加载
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 加载源码中
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 已加载源码,还未bootstrap
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // bootstrap中
export const NOT_MOUNTED = "NOT_MOUNTED"; // bootstrap完毕,还未mount
export const MOUNTING = "MOUNTING"; // mount中
export const MOUNTED = "MOUNTED"; // mount结束
export const UPDATING = "UPDATING"; // updata中
export const UNMOUNTING = "UNMOUNTING"; // unmount中
export const UNLOADING = "UNLOADING"; // unload中
export const LOAD_ERROR = "LOAD_ERROR"; // 加载源码时加载失败
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; // 在load,bootstrap,mount,unmount阶段发生脚本错误
我们可以在开发时使用官方的调试工具快速查看每次路由变更后每个应用的状态:
single-spa
使用了有限状态机
的设计思想:
- 事物拥有多种状态,任一时间只会处于一种状态不会处于多种状态;
- 动作可以改变事物状态,一个动作可以通过条件判断,改变事物到不同的状态,但是不能同时指向多个状态,一个时间,就一个状态;
- 状态总数是有限的。
有限状态机的其他例子: Promise 、 红绿灯
1.6 single-spa 的事件系统
基于浏览器原生的事件系统,无框架耦合,全局开箱可用。
// 接收方式
window.addEventListener("single-spa:before-routing-event", (evt) => {
const {
originalEvent,
newAppStatuses,
appsByNewStatus,
totalAppChanges,
} = evt.detail;
console.log(
"original event that triggered this single-spa event",
originalEvent
); // PopStateEvent | HashChangeEvent | undefined
console.log(
"the new status for all applications after the reroute finishes",
newAppStatuses
); // { app1: MOUNTED, app2: NOT_MOUNTED }
console.log(
"the applications that changed, grouped by their status",
appsByNewStatus
); // { MOUNTED: ['app1'], NOT_MOUNTED: ['app2'] }
console.log(
"number of applications that changed status so far during this reroute",
totalAppChanges
); // 2
});
1.7 single-spa 亮点与不足
亮点
- 全异步编程,对于用户需要提供的 load,bootstrap,mount,unmount 均使用 promise 异步的形式处理,不管同步、异步都能 hold 住
- 通过劫持路由,可以在每次路由变更时先判断是否需要切换应用,再交给子应用去响应路由
- 标准化每个应用的挂载和卸载函数,不耦合任何框架,只要子应用实现了对应接口即可接入系统中
不足
load
方法需要知道子项目的入口文件- 把多个应用的运行时集成起来需要项目间自行处理内存泄漏,样式污染问题
- 没有提供父子数据通信的方式
2. qiankun 登场
为了解决single-spa
的一些不足,以及保留single-spa
中优秀的理念,所以qiankun
在single-spa
的基础上进行了更进一步的拓展。
以下是qiankun
官方给的能力图:
我们来看看qiankun
的使用方式
import { registerMicroApps, start } from "qiankun";
registerMicroApps([
{
name: "react app", // app name registered
entry: "//localhost:7100",
container: "#yourContainer",
activeRule: "/yourActiveRule",
},
{
name: "vue app",
entry: { scripts: ["//localhost:7100/main.js"] },
container: "#yourContainer2",
activeRule: "/yourActiveRule2",
},
]);
start();
是不是有点像single-spa
的注册方式?
2.1 传递注册信息给 single-spa
实际上qiankun
内部会把用户的应用注册信息包装
后传递给single-spa
import { registerApplication } from "single-spa";
export function registerMicroApps(apps) {
apps.forEach((app) => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;
registerApplication({
name,
app: async () => {
loader(true);
const { mount, ...otherMicroAppConfigs } = await loadApp(
{ name, props, ...appConfig },
frameworkConfiguration
);
return {
mount: [
async () => loader(true),
...toArray(mount),
async () => loader(false),
],
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
});
}
可以看到mount
和unmount
函数是由loadApp
返回的。
2.2 loadApp 的实现
export async function loadApp(app, configuration) {
const { template, execScripts } = await importEntry(entry); // 通过应用的入口链接即可获取到应用的html, js, css内容
const sandboxInstance = createSandbox(); // 创建沙箱实例
const global = sandboxInstance.proxy; // 获取一个沙箱全局上下文
const mountSandbox = sandboxInstance.mount;
const unmountSandbox = sandboxInstance.unmount;
// 在这个沙箱全局上下文执行子项目的js代码
const scriptExports = await execScripts(global);
// 获取子项目导出的 bootstrap / mount / unmount
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global
);
// 初始化事件模块
const {
onGlobalStateChange,
setGlobalState,
offGlobalStateChange,
} = getMicroAppStateActions();
// 传递给single-spa的mount, unmount方法实际是qiankun包装过的函数
return {
bootstrap,
mount: async () => {
awaitrender(template); // 把模板渲染到挂载区域
mountSandbox(); // 挂载沙箱
await mount({ setGlobalState, onGlobalStateChange }); // 调用应用的mount函数
},
ummount: async () => {
await ummount(); // 调用应用的ummount函数
unmountSandbox(); // 卸载沙箱
offGlobalStateChange(); // 解除事件监听
render(null); // 把渲染区域清空
},
};
}
2.3 importEntry 的实现
看看 importEntry
的使用,这是一个独立的包 import-html-entry
,通过解析一个 html 内容,返回html
, css
,js
分离过的内容。
例如一个子应用的入口html
为如下
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>这里是标题</title>
<link rel="stylesheet" href="./css/admin.css" />
<style>
.div {
color: red;
}
</style>
</head>
<boyd>
<div id="wrap">
<div id="app"></div>
</div>
<script src="/static/js/app.12345.js"></script>
<script>
console.log("1");
</script>
</boyd>
</html>
被 qiankun
加载到页面后,最终生成的 html 结构
为:
<meta charset="utf-8" />
<title>这里是标题</title>
<link rel="stylesheet" href="./css/admin.css" />
<style>
.div {
color: red;
}
</style>
<div id="wrap">
<div id="app"></div>
</div>
<!-- script /static/js/app.12345.js replaced by import-html-entry -->
<!-- inline scripts replaced by import-html-entry -->
看看importEntry
返回的内容
export function importEntry(entry, opts = {}) {
... // parse html 过程忽略
return {
// 纯dom元素的内容
template,
// 一个可以接收自定义fetch方法的获取<script>标签的方法
getExternalScripts: () => getExternalScripts(scripts, fetch),
// 一个可以接收自定义fetch方法的获取<style>标签的方法
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
// 一个接收全局上下文的执行函数,执行这个方法则模拟了一个应用加载时浏览器执行script脚本的逻辑
execScripts: (proxy) => {}
}
}
看看getExternalScripts
的实现,实际上是用并行fetch
模拟浏览器加载<style>标签
的过程(注意此时还没有执行这些脚本), getExternalStyleSheets
与这个类似。
// scripts是解析html后得到的<scripts>标签的url的数组
export getExternalScripts(scripts, fetch = defaultFetch) {
return Promise.all(scripts.map(script => {
return fetch(scriptUrl).then(response => {
return response.text();
}));
}))
}
然后看看execScripts
的实现,可以通过给定的一个假window
来执行所有<script>标签
的脚本,这样就是真正模拟了浏览器执行<script>标签
的行为。
export async execScripts(proxy) {
// 上面的getExternalScripts加载得到的<scripts>标签的内容
const scriptsTexts = await getExternalScripts(scripts)
window.proxy = proxy;
// 模拟浏览器,按顺序执行script
for (let scriptsText of scriptsTexts) {
// 调整sourceMap的地址,否则sourceMap失效
const sourceUrl = '//# sourceURL=${scriptSrc}\n';
// 通过iife把proxy替换为window, 通过eval来执行这个script
eval(`
;(function(window, self){
;${scriptText}
${sourceUrl}
}).bind(window.proxy)(window.proxy, window.proxy);
`;)
}
}
2.4 全局变量污染与内存泄漏
看沙箱功能前先聊一聊沙箱,沙箱主要用于解决程序的全局变量污染
和内存泄漏
问题。
- 全局变量污染: 多个应用都使用某个同名全局变量,例如 Vue。
内存泄漏: 内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。
常见的内存泄漏场景:
- 意外的全局变量
- 泄漏到全局的闭包
- DOM 泄漏
- 定时器
- EventListener
- console.log (开发环境)
下面我们看看qiankun
要如何解决上面的问题。
2.5 qiankun 如何使用沙箱
可以结合上文loadApp
的逻辑看看,本文讨论的是LegacySandbox
沙箱。
export function createSandbox() {
const sandbox = new LegacySandbox();
// load或者bootstrap阶段产生的污染和泄漏
const bootstrappingFreers = patchAtBootstrapping();
let sideEffectsRebuilders = [];
return {
proxy: sandbox.proxy,
// 沙箱被 mount, 可能是从 bootstrap 状态进入的 mount, 也可能是从 unmount 之后再次唤醒进入 mount
async mount() {
/* ------------------------------------------ 1. 启动/恢复 沙箱------------------------------------------ */
sandbox.active();
const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(
0,
bootstrappingFreers.length
);
// 重建应用bootstrap阶段的副作用,比如动态插入css
sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild());
/* ------------------------------------------ 2. 开启全局副作用监听 ------------------------------------------*/
// render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化bootstrap阶段有 事件监听/定时器 等副作用,这些副作用无法清除
mountingFreers = patchAtMounting(
appName,
elementGetter,
sandbox,
singular,
scopedCSS,
excludeAssetFilter
);
sideEffectsRebuilders = [];
},
// 恢复 global 状态,使其能回到应用加载之前的状态
async unmount() {
// 每个Freers释放后都会返回一个重建函数,如果该Freers不需要重建,则是返回一个空函数
sideEffectsRebuilders = [...bootstrappingFreers].map((free) => free());
sandbox.inactive();
},
};
}
看看LegacySandbox
沙箱的实现,这个沙箱的作用主要处理全局变量污染,使用一个proxy
来替换window
来劫持所有的 window 操作。
class SingularProxySandbox {
// 沙箱期间更新的全局变量
addedPropsMapInSandbox = new Map();
// 沙箱期间更新的全局变量
modifiedPropsOriginalValueMapInSandbox = new Map();
// 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot
currentUpdatedPropsValueMap = new Map();
sandboxRunning = true;
active() {
// 把上次该沙箱运行时的快照还原
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
this.sandboxRunning = true;
}
inactive() {
// 沙箱销毁时把修改的值改回去
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
// 沙箱销毁时把新增的值置空
this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
constructor(name) {
const proxy = new Proxy(window, {
set(_, p, value) {
// 如果当前 window 对象不存在该属性,则记录该属性是新增的
if (!window.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
// 如果当前 window 对象存在该属性,且 map 中未记录过,则记录该属性被修改及保存修改前的值
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
const originalValue = window[p];
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
// 不管新增还是修改,这个值都变成最新的快照记录起来
currentUpdatedPropsValueMap.set(p, value);
window[p] = value;
}
},
get(_, p) {
return window[p]
},
})
}
}
除了全局变量污染的问题,还有其他的泄漏问题需要处理,这些泄漏问题qiankun
使用不同的patch函数
来劫持。
// 处理mount阶段和应用运行阶段产生的泄漏
export function patchAtMounting() {
return [
// 处理定时器泄漏
patchInterval(),
// 处理全局事件监听泄漏
patchWindowListener(),
patchHistoryListener(),
// 这个严格不算泄漏,是监听动态插入页面的dom结构(包括script和style)
patchDynamicAppend(),
];
}
// 处理load和bootstrap阶段产生的泄漏
export function patchAtBootstrapping() {
return [patchDynamicAppend()];
}
一个patch
的例子如下:
const rawWindowInterval = window.setInterval;
const rawWindowClearInterval = window.clearInterval;
export default function patchInterval(global) {
let intervals = [];
global.clearInterval = (intervalId) => {
intervals = intervals.filter((id) => id !== intervalId);
return rawWindowClearInterval(intervalId);
};
global.setInterval = (handler, timeout, ...arg) => {
const intervalId = rawWindowInterval(handler, timeout, ...args);
intervals = [...intervals, intervalId];
return intervalId;
};
// 返回释放这些泄漏的方法
return function free() {
intervals.forEach((id) => global.clearInterval(id));
global.setInterval = rawWindowInterval;
global.clearInterval = rawWindowClearInterval;
// 这个patch有没有需要重建的场景,如果没有,则为空函数
return function rebuild() {};
};
}
这种返回取消功能
的设计很精妙,在 vue 中也能找到类似设计。
// 监听返回取消监听方法,取消监听返回再重新监听的方法
const unwatch = this.$watch("xxx", () => {});
const rewatch = unwatch(); // 伪代码,实际上没有
我们来看最复杂的patchDynamicAppend
实现,用于处理代码里动态插入script
和link
的场景。
const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;
export default function patchDynamicAppend(mounting, proxy) {
let dynamicStyleSheetElements = [];
// 劫持插入函数
HTMLHeadElement.prototype.appendChild = function(element) {
switch (element.tagName) {
case LINK_TAG_NAME:
// 如果是动态插入<style>标签到body上,则调整插入的位置到子应用挂载区
case STYLE_TAG_NAME: {
dynamicStyleSheetElements.push(stylesheetElement);
return rawHeadAppendChild.call(appWrapperGetter(), stylesheetElement);
}
// 如果是动态插入<script>标签,则使用execScripts来执行这个脚本,然后把脚本替换为一段注释文本表示已执行过
case SCRIPT_TAG_NAME: {
const { src, text } = element;
execScripts(null, [src ? src : `<script>${text}</script>`], proxy);
const dynamicScriptCommentElement = document.createComment(
src
? `dynamic script ${src} replaced by qiankun`
: "dynamic inline script replaced by qiankun"
);
return rawHeadAppendChild.call(
appWrapperGetter(),
dynamicScriptCommentElement
);
}
}
return rawHeadAppendChild.call(this, element);
};
// 这里free不需要释放什么东西,因为style元素会随着内容区清除而自然消失
return function free() {
// 这里需要再下次继续挂载这个应用时重建style元素
return function rebuild() {
dynamicStyleSheetElements.forEach((stylesheetElement) => {
document.head.appendChild.call(appWrapperGetter(), stylesheetElement);
});
if (mounting) dynamicStyleSheetElements = [];
};
};
}
2.6 父子应用通信
qiankun
实现了一个简单的全局数据存储,作为single-spa
事件的补充,父子应用都可以共同读写这个存储里的数据。
let globalState = {};
export function getMicroAppStateActions(id, isMaster) {
return {
// 事件变更回调
onGlobalStateChange(callback, fireImmediately) {
deps[id] = callback;
const cloneState = cloneDeep(globalState);
if (fireImmediately) {
callback(cloneState, cloneState);
}
},
// 设置全局状态
setGlobalState(state) {
const prevGlobalState = cloneDeep(globalState);
Object.keys(deps).forEach((id) => {
deps[id](cloneDeep(globalState), cloneDeep(prevGlobalState));
});
return true;
},
// 注销该应用下的依赖
offGlobalStateChange() {
delete deps[id];
},
};
}
2.7 关于预请求
预请求充分利用了importEntry
把获取资源和执行资源分离的点来提前加载所有子应用的资源。
function prefetch(entry, opts) {
if (!navigator.onLine || isSlowNetwork) {
// Don't prefetch if in a slow network or offline
return;
}
requestIdleCallback(async () => {
const { getExternalScripts, getExternalStyleSheets } = await importEntry(
entry,
opts
);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}
apps.forEach(({ entry }) => prefetch(entry, opts));
以上分享了qiankun
和single-spa
的原理,总的来说qiankun
更面向一些子项目不可控,并且开发者不会刻意处理污染和内存泄漏的场景,而single-spa
则更纯粹的是一个路由
控制器,所有的污染和泄漏问题都需要开发者自行约束。
3. OPPO 云实践
OPPO云
在实践qiankun微前端
的落地过程中,也摸索出一些经验可进行分享。
3.1 关于沙箱
qiankun 的沙箱不是万能的
- 沙箱只有一层的劫持,例如 Date.prototype.xxx 这样的改动是不会被还原的
- 目前沙箱对于全局变量的作用在于屏蔽,而不是清除,并且屏蔽后这部分内存是保留的,后续会开放自定义沙箱的能力
- 关于内存泄漏的概念,可以了解一下“常驻内存”的概念
常驻内存是一种辅助工具程序,能假装退出,而仍驻留于内存当中,让你运行其它的应用,当你再切回应用时,可以立即应用这些内存,而不需要再次耗时创建
- 排查内存问题时请使用无痕模式以及不使用任何 chrome 拓展,也推荐使用生产构建来排查
3.2 提取公共库
qiankun
不建议共享依赖,担心原型链污染
等问题。single-spa
推荐共享大型依赖,需要小心处理污染问题,它们都是推荐使用webpack
的external
来共享依赖库。- 我们也推荐共享大的公共依赖,也是使用
webpack
的external
来共享依赖库,不过是每个子应用加载时都重复再加载一次库,相当于节省了相同库的下载时间,也保证了不同子应用间不会产生原型链污染
,属于折中
的方案。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。