
最近在做Uniapp跨端项目时产品提了一个听起来很“常规”的需求不同角色登录后底部tabbar展示的菜单不一样。比如普通用户看到“首页/分类/购物车/我的”管理员却要看“工作台/数据/我的”游客则只能看到“首页/我的”。当时第一反应是这不就是改一下tabbar配置吗真动手才发现Uniapp的底部tabbar一旦写进pages.json就基本被“焊死”了想根据权限动态控制绕不开一套专门的设计。这篇文章就从头到尾拆一遍这个需求讲清楚我在实际项目中是怎么做的。内容覆盖方案选型、权限数据设计、自定义tabbar组件实现、多角色动态渲染、以及角标和页面缓存等容易踩坑的细节。无论你是正准备接这个需求还是已经在改造的路上卡住了这篇文章都能给你一套可以直接落地的思路。1. 需求拆解与方案选型为什么原生tabBar一条路走不通1.1 动态tabbar需求表面简单实际要处理的细节不少很多同学看到“根据角色动态展示底部tabbar”第一反应就是把菜单列表和角色条件关联起来然后动态渲染。但实际上一个完整的动态tabbar需求至少包含下面这几层问题。一是菜单本身的结构差异。角色不同底部tabbar的数量可能不同可能是4个可能是3个甚至可能是5个。菜单项对应的页面路径也可能完全不同管理员的工作台页面普通用户根本访问不到。二是权限变化后的同步问题。用户登录、退出、切换账号这些操作发生之后tabbar必须立刻跟着变。尤其在小程序里用户可能杀掉小程序再重新进入这时候tabbar需要从本地存储里恢复出正确的角色菜单。三是页面被“绕过”的风险。虽然底部tabbar上看不到某个入口但用户完全有可能通过历史记录、分享链接、扫码等方式直接打开一个他没有权限的页面。页面打开后底部tabbar该显示成什么样要不要强制跳回首页这些都是要考虑的。四是隐藏菜单项不能让用户感觉“系统坏了”。比如页面切换时底部栏闪一下、菜单高亮错乱、页面栈残留导致返回键行为诡异这些小毛病都会让用户觉得开发团队不专业。所以动态tabbar不是“能不能隐藏某个tab”这一件事而是一整套“角色-菜单-路由-页面状态”的联动方案。1.2 原生tabBar的能力边界能改文案改不了结构Uniapp原生的tabBar是在pages.json里全局配置的这东西的特点是稳定、有原生手感、切换页面不刷新而且小程序端和App端的性能都很好。但它的缺点也很明显结构是静态的。pages.json里的list一旦写好运行时不能直接对list做增删改。官方确实提供了 uni.setTabBarItem 这个API它可以动态修改某个tab的文案和图标但它有几个硬伤。第一它只能改已有项的文字和图标不能增加一项也不能删除一项。比如普通用户是4个tab管理员是5个tab那pages.json里list的长度最多只能是5普通用户登录后就得想办法“藏掉”第5个tab。但原生tabBar并不支持藏掉单项。第二确实有开发者会用 uni.hideTabBar() 来隐藏整个tabbar但这个方法只能整条隐藏不能只隐藏某一项。如果隐藏整个tabbar那tab页面之间就没法用原生切换了底部导航也不见了那就不是“动态展示”而是“动态隐藏”。第三即使通过一些黑科技强行操作视觉效果也有问题。原生tabbar在页面切换时往往有短暂的“闪现”或“跳动”体验很生硬。所以原生tabBar适合角色固定、菜单固定的场景。一旦牵扯到不同角色看到不同选项卡就得换思路。1.3 三条实现路线横评我为什么最后选了自定义组件在实际调研中社区里关于动态tabbar主要有三种实现路线。第一种是“原生tabBar 全部注册 运行时隐藏/修改”。也就是在pages.json里把可能用到的tab全部注册了然后用 uni.hideTabBar 把不需要显示的场景整条隐藏。这种做法实现成本最低但隐藏逻辑很粗糙控制不了单个菜单项也没法应对“5个菜单和4个菜单之间切换”这种复杂需求只适合那种“用户登出后整个tabbar消失”的极简场景。第二种是“多个tabBar配置 启动时选择入口”。把不同角色需要的tab配置维护在多个地方启动时根据角色reLaunch到对应的首页。这方案其实是在“切入口”不是“切tabbar”。缺点是页面配置会被拆得七零八落相同语义的页面可能要复制好几份维护成本巨大。第三种是“完全自定义TabBar组件”。pages.json里不配置原生tabBar所有tab页面都以普通页面方式注册然后写一个自定义的底部菜单组件在需要展示tabbar的页面里引入。组件内部根据当前用户的角色从菜单配置里算出应该渲染哪些菜单项。我最终选的是第三种因为它在灵活性上几乎是碾压级的菜单增删、图标选中态、角标、中间凸起按钮、点击拦截这些需求全都能在组件内部优雅实现。代价是要自己多写一些代码而且tab页面的切换逻辑要自己控制。但长远来看这套方案的可维护性远超前两种。2. 权限数据与菜单配置先把树根扎稳2.1 角色与菜单映射表一份配置说清楚谁能看到什么写代码之前我建议先把“角色能看到哪些菜单”这个规则定义清楚不要让它散落在页面里。最直观的方式是维护一份菜单配置数据每个菜单项里声明哪些角色可以访问。// config/menuConfig.js // 菜单枚举一个角色对应一个menuIds数组 export const ROLE_MENU_MAP { guest: [home, mine], user: [home, category, cart, mine], admin: [home, workbench, data, mine] } // 菜单全量配置 export const ALL_MENUS [ { id: home, title: 首页, icon: /static/tabbar/home.png, activeIcon: /static/tabbar/home-active.png, path: /pages/home/index }, { id: workbench, title: 工作台, icon: /static/tabbar/workbench.png, activeIcon: /static/tabbar/workbench-active.png, path: /pages/workbench/index }, { id: data, title: 数据, icon: /static/tabbar/data.png, activeIcon: /static/tabbar/data-active.png, path: /pages/data/index }, { id: category, title: 分类, icon: /static/tabbar/category.png, activeIcon: /static/tabbar/category-active.png, path: /pages/category/index }, { id: cart, title: 购物车, icon: /static/tabbar/cart.png, activeIcon: /static/tabbar/cart-active.png, path: /pages/cart/index }, { id: mine, title: 我的, icon: /static/tabbar/mine.png, activeIcon: /static/tabbar/mine-active.png, path: /pages/mine/index } ] // 根据角色获取最终菜单 export function getMenusByRole(role) { const menuIds ROLE_MENU_MAP[role] || ROLE_MENU_MAP.guest return ALL_MENUS.filter(item menuIds.includes(item.id)) }这份配置的好处是以后新增角色或者调整菜单权限只需要改ROLE_MENU_MAP和ALL_MENUS两个地方不需要动组件代码也不需要动页面代码。菜单顺序就按ALL_MENUS里的顺序展示如果你是希望按角色定制顺序可以把menuIds的顺序直接当作展示顺序处理实现时按这个数组的次序来返回。2.2 登录后权限数据的存储时机与用法权限数据本身的来源通常是登录接口返回的用户信息里的角色字段。在Uniapp里我一般这么处理登录成功后把用户信息和角色标识存进本地缓存同时也同步到全局状态。// store/index.js import Vue from vue import Vuex from vuex Vue.use(Vuex) const store new Vuex.Store({ state: { token: uni.getStorageSync(token) || , userInfo: uni.getStorageSync(userInfo) || null }, mutations: { setToken(state, token) { state.token token uni.setStorageSync(token, token) }, setUserInfo(state, userInfo) { state.userInfo userInfo uni.setStorageSync(userInfo, userInfo) }, logout(state) { state.token state.userInfo null uni.removeStorageSync(token) uni.removeStorageSync(userInfo) } }, getters: { role(state) { return (state.userInfo state.userInfo.role) || guest } } })为什么既要存Vuex又要存Storage因为Vuex是内存态响应式好组件里能即时感知变化Storage是持久化App冷启动、小程序被杀后重进时都要靠Storage恢复登录态和角色。两者不能互相替代。登录成功后类似这样调用async function handleLogin(loginForm) { const res await api.login(loginForm) // 假设后端返回的数据里包含角色信息 const { token, userInfo } res.data store.commit(setToken, token) store.commit(setUserInfo, userInfo) uni.reLaunch({ url: /pages/home/index }) }这里用 uni.reLaunch 而不是 uni.switchTab也是因为后面要介绍的方案中首页并不是原生tabBar页面后面会细说。2.3 冷启动、热切换、被分享进入都要拦截一遍动态tabbar最容易被忽略的是“进小程序时的路由入口问题”。如果用户之前停留在一个小程序页面然后杀掉小程序几天后重新打开微信默认会尝试恢复上次的页面路径。如果你的自定义tabbar方案在App.vue的onLaunch阶段就去读vuex里的角色数据这时候可能数据还没准备好页面已经渲染了。所以我在App.vue的onLaunch里会做一次用户身份初始化onLaunch() { // 从storage恢复登录信息到vuex const userInfo uni.getStorageSync(userInfo) if (userInfo) { this.$store.commit(setUserInfo, userInfo) } }这还不够。某个tabbar页面还可能在用户没有登录、或者角色不对的情况下被打开。比如管理员专属的工作台页面被分享给了一个普通用户普通用户点开链接直接进入。这时候页面底部的tabbar组件需要根据当前角色渲染但如果渲染出来的菜单里根本没有工作台这项当前页却还停留在工作台用户就会看到一个“悬浮”的页面底部菜单高亮也没有对应项。这种体验很糟糕。所以不仅是tabbar组件要根据角色渲染页面自身也要做权限校验。我会在需要展示tabbar的每个页面onShow里检查当前角色是否拥有当前页面的菜单权限没有权限就reLaunch到首页并在URL里带上来源标记方便后续埋点。3. 自定义TabBar组件从零搭一个能用的底部导航3.1 组件模板与基础渲染逻辑先写一个自定义组件 components/TabBar.vue。这个组件做的事情是根据角色算出菜单列表再根据当前页面路径判断高亮哪一项。template view classtab-bar view v-for(item, index) in menus :keyitem.id classtab-bar__item :class{ active: current item.path } clickhandleSwitch(item, index) image classtab-bar__icon :srccurrent item.path ? item.activeIcon : item.icon / text classtab-bar__title{{ item.title }}/text !-- 预留角标位 -- view v-ifbadgeMap[item.id] classtab-bar__badge {{ badgeMap[item.id] }} /view /view /view /template script import { getMenusByRole } from /config/menuConfig.js export default { name: TabBar, props: { // 当前页面的完整路径比如 /pages/home/index current: { type: String, default: } }, data() { return { badgeMap: {} } }, computed: { menus() { const role this.$store.getters.role return getMenusByRole(role) } }, mounted() { // 如果有角标需求可以从全局状态或事件里更新badgeMap this.updateBadge() }, methods: { handleSwitch(item) { const url item.path if (url this.current) { return } uni.reLaunch({ url }) }, updateBadge() { // 这里根据业务状态更新角标 // 比如购物车数量等 } } } /script这里有两个设计细节想特别说明。一是当前激活项我通过prop传current让页面把自己当前的完整路径传进来。组件拿到current和菜单项path做对比就能精确高亮。这个方式比在组件里监听路由要简单可靠得多。二是切换方式我用了uni.reLaunch。为什么不是uni.switchTab因为uni.switchTab只对原生tabBar页面有效咱们没有配置原生tabBar用了会直接报错。也不是uni.navigateTo因为navigateTo是压栈跳转底部tab切换时页面栈会越堆越深返回键会退回上一个tab页完全不符合tabbar的直觉。用uni.reLaunch会把当前页面栈全部清掉再打开新页面这样底部tab切换就是“平级跳转”返回键退出的是整个页面栈体验才像正常的tabbar。3.2 图标选中态与角标位的处理自定义tabbar的图标我建议直接采用本地静态图片方案。早期我也用过字体图标好处是颜色可以随意切换但Uniapp在小程序端对字体文件路径和网络字体支持比较折腾不同平台表现还不一致。后来换成了“两套PNG图标”普通状态一张灰色选中状态一张高亮图在模板里根据激活状态切换src最省心。图标目录我建议建一个专门的目录存放命名上统一成“首页.png / 首页-active.png”这种一眼能看懂的格式。角标这块原生tabBar提供了一个uni.setTabBarBadge接口但自定义tabbar里根本用不了。所以我在组件里放了一个badgeMap对象每个菜单id对应的角标数字或小红点都往这个map里填。可以用Vuex管理全局角标数据组件里从store里取也可以提供updateBadge方法让外部调用看你的项目结构。比如购物车tab的角标// store 里维护一个 shopCartCount this.$store.commit(setCartCount, res.data.totalCount)然后在组件watch里监听cartCount一旦变化就更新badgeMap.cart。这样小红点就能在多个页面之间同步更新。3.3 底部安全区与不同平台适配自定义tabbar最烦的就是适配。App端、H5端、小程序端的底部都有一个安全区的问题尤其iPhone X以后那些带底部横条的设备如果组件不做适配菜单会被home indicator挡住。我推荐直接用CSS的env()和constant()来做安全区适配.tab-bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 999; display: flex; height: 100rpx; padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); background: #ffffff; box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06); }注意constant()要写在env()前面这是iOS的兼容顺序写反了旧设备不认。高度用了100rpx加上底部安全区的padding整体视觉高度会比原生tabbar略高一点但和内容过渡自然。页面内容也要处理。因为tabbar是fixed在底部的页面主体内容如果滚动到最后容易被tabbar遮住。解决办法是给每个tab页面最外层容器加padding-bottom一般加120rpx到140rpx足够了。H5端还要额外注意路由模式。如果你用的history模式刷新页面后要重新定位到当前路由对应的tabhash模式相对简单因为路由信息在hash里页面刷新后还能从hash定位。建议H5部署时用hash模式能少很多奇奇怪怪的问题。小程序端则要记住自定义tabbar不能使用cover-view方案里的原生tabbar但是小程序的基础库版本必须在2.5.0以上才支持自定义tabbar的API这里想岔了我们这种方案压根不用原生tabbar所以对基础库的要求很低普通情况下都能跑。3.4 页面跳转与当前激活项联动页面跳转在组件内部的方法就是handleSwitch。除了reLaunch还有一个细节如果用户点击的正好是当前页面什么都不用做直接return避免无意义的页面重载。每个使用自定义tabbar的页面结构大概是这样的template view classpage-container !-- 页面内容 -- TabBar :currentcurrentPath / /view /template script export default { data() { return { currentPath: /pages/home/index } }, onShow() { // 以当前页面自身路径为准 this.currentPath /pages/home/index } } /scriptcurrentPath写死在自己的页面里最稳妥不需要从路由动态解析减少出错概率。这里要提到一个常见的坑如果某个tab页面需要接收参数比如从列表页跳转到首页并携带来源标记使用uni.reLaunch时可以直接拼接query例如 /pages/home/index?fromorder。页面在onLoad里用options接住即可。但是onLoad在reLaunch场景下每次都会被触发这点和原生tabbar的“页面常驻”完全不同。所以页面里如果有比较重的初始化请求要注意缓存和防重复加载。4. 多角色动态渲染让同一套代码适配N种用户4.1 根据角色筛选菜单写一个getMenusByRole这一步其实就是菜单配置的核心函数。前面已经列了代码这里再展开讲一下设计思路。getMenusByRole接收一个角色字符串返回菜单数组。做三件事第一根据角色从ROLE_MENU_MAP拿菜单id数组第二按id从ALL_MENUS里过滤出对应菜单项第三如果角色没匹配到任何配置就回退成guest游客菜单保证系统不会白屏或者裸奔。export function getMenusByRole(role) { const menuIds ROLE_MENU_MAP[role] || ROLE_MENU_MAP.guest return menuIds .map(id ALL_MENUS.find(item item.id id)) .filter(Boolean) }这里还有一个进阶需求如果不同角色看到的tab顺序不一样怎么办简单ROLE_MENU_MAP里的数组顺序就是展示顺序menuIds里的顺序是什么菜单就按什么排。Admin想让“数据”排在“工作台”前面直接把ROLE_MENU_MAP.admin的数组顺序调整即可。需要注意模板里v-for渲染时已经通过computed拿到了当前角色的menus所以不会出现在模板里再嵌套筛选逻辑这种尴尬局面。小程序的WXML模板不支持调用复杂函数所以提前在computed里算好非常关键。4.2 登录状态变化后如何刷新tabbar因为自定义组件里的menus是computed属性依赖vuex里的role所以当角色变化时组件会立刻跟着变。这是Vuex响应式带来的最大便利。不过有一个隐藏问题tabbar组件是常驻在各个页面上的登录时如果从“登录页”reLaunch到首页首页的TabBar组件重新挂载会通过computed重新计算角色菜单没问题。但如果你已经在一个tab页里然后用户切了账号这时候往往需要reLaunch到一个新首页而不是“原地刷新”。所以我在登录和切换账号的公共方法里统一reLaunch到首页。退出登录也一样需要把角色清掉再reLaunch到游客首页。function handleLogout() { store.commit(logout) uni.reLaunch({ url: /pages/home/index }) }这样做之后页面栈清空所有页面重新挂载tabbar从computed里重新取到的就是guest菜单整个过程非常干净。4.3 无权限页面兜底即使被分享进去也要拦得住tabbar组件虽然能根据不同角色渲染不同菜单但页面自身的权限校验不能少。我一般写一个混入mixin每个tab页面都混入它在onShow里做校验// mixins/tabPageMixin.js import { getMenusByRole } from /config/menuConfig.js export default { onShow() { const role this.$store.getters.role const menus getMenusByRole(role) const paths menus.map(item item.path) const currentPath / this.$route if (paths.indexOf(currentPath) -1) { uni.reLaunch({ url: /pages/home/index (this.$route ? ?redirect encodeURIComponent(this.$route) : ) }) } } }$route在Uniapp的小程序端需要自定义在小程序里可以通过pages获取当前页面路径有个getCurrentPages()函数可以用。简单封装一下function getCurrentPageUrl() { const pages getCurrentPages() const currentPage pages[pages.length - 1] return / currentPage.route }把当前页路径拼出来如果在用户角色对应的菜单里找不到就reLaunch到首页。这样可以保证普通用户就算拿到了管理员的页面链接打开后也会被拉回自己的首页而不是看到一个没有tabbar、或tabbar没有高亮的“孤儿页面”。5. 完整示例三分钟跑通一个“用户/管理员”双角色Demo5.1 目录结构与pages.json处理说了这么多直接上一个能跑的Demo。假设我做一个极简商城角色分两种user用户和 admin管理员。用户端底部菜单是“首页/分类/购物车/我的”管理员端底部菜单是“首页/工作台/数据/我的”。pages.json里不配原生tabBar只把这些页面注册为普通页面。第一项设置成启动页也就是首页。{ pages: [ { path: pages/home/index, style: { navigationBarTitleText: 首页 } }, { path: pages/category/index, style: { navigationBarTitleText: 分类 } }, { path: pages/cart/index, style: { navigationBarTitleText: 购物车 } }, { path: pages/mine/index, style: { navigationBarTitleText: 我的 } }, { path: pages/workbench/index, style: { navigationBarTitleText: 工作台 } }, { path: pages/data/index, style: { navigationBarTitleText: 数据 } } ], globalStyle: { navigationBarTextStyle: black, navigationBarTitleText: uni-app, navigationBarBackgroundColor: #F8F8F8, backgroundColor: #F8F8F8 } }5.2 核心代码菜单配置、Store、TabBar组件、页面引入菜单配置已经在前面给了Store也给了TabBar组件也给了这里补一下页面里的引入方式。以首页为例template view classpage-home button clickswitchRole(admin)切换为管理员/button button clickswitchRole(user)切换为普通用户/button TabBar current/pages/home/index / /view /template script import TabBar from /components/TabBar.vue export default { components: { TabBar }, methods: { switchRole(role) { const userInfo { role } this.$store.commit(setUserInfo, userInfo) uni.reLaunch({ url: /pages/home/index }) } } } /script其他每个tab页面都类似在自己的页面里引入TabBar组件把current设成自己的路径。5.3 验证方法怎么确认不同角色看到的是不同菜单跑起来之后验证也很简单启动项目先看默认游客状态底部只有“首页/我的”点击“切换为普通用户”reLaunch回首页底部变成“首页/分类/购物车/我的”再点击“切换为管理员”底部变成“首页/工作台/数据/我的”。这个验证过程同时也验证了tabbar的响应式逻辑。如果切换后菜单没有变化先检查store里role是否更新成功再看TabBar组件是否被重新挂载。常见的坑是切换角色后没有reLaunch页面停留在旧页面这时候旧页面里虽然computed已经更新但因为没有重新挂载菜单可能不刷新。6. 进阶玩法角标、拦截、页面缓存这些躲不掉的坑6.1 底部角标的通用实现自定义tabbar组件加持下角标实现非常简单。在TabBar组件里维护badgeMap然后监听全局状态。比如购物车角标Store里用一个cartCount// store mutations setCartCount(state, count) { state.cartCount count }TabBar组件里watch一下cartCountwatch: { cartCount(newVal) { this.badgeMap { ...this.badgeMap, cart: newVal 99 ? 99 : String(newVal) } } }如果不想用字符串而是展示一个红点可以把badgeValue改成布尔值模板里v-ifbadgeMap[item.id]判断。相比原生setTabBarBadge这种方案完全可控想怎么展示就怎么展示。6.2 权限切换后“页面缓存”把tabbar卡老的坑我在早期版本里吃到过一个大亏用了自定义tabbar后从普通用户切换成管理员然后从管理员tab页点返回居然回到了普通用户页面而且底部tabbar高亮还停留在一个不存在的菜单上。后来定位到原因切换角色时我没有清空页面栈旧的tab页面其实还残留在页面栈底部。解决办法就是前面反复强调的登录、退出、切换角色这些操作全部用uni.reLaunch不要用uni.navigateTo。reLaunch会关闭所有页面并打开新页面从根本上避免旧页面残留。如果有些业务必须保留页面栈比如“从订单详情页退出登录”那你至少要保证退出登录后把不需要的页面从栈里清掉。Uniapp没有官方API来“弹掉某个页面”所以最简单的方案还是详情页点退出后reLaunch到登录页。不要舍不得那点页面状态。6.3 监听tab点击做拦截和统计原生tabbar在小程序端有一个 onTabItemTap 页面生命周期函数但只在“页面是原生tabBar页面”时有效。我们在自定义tabbar场景下监听点击的行为直接写在组件click事件里就可以了。如果需要在某些tab上做拦截比如“购物车需要登录后才能进入”可以在handleSwitch里加判断handleSwitch(item) { const url item.path if (url this.current) { return } if (url /pages/cart/index !this.$store.state.token) { uni.navigateTo({ url: /pages/login/index }) return } uni.reLaunch({ url }) }甚至可以在点击事件里打点上报统计用户点了哪个底部入口什么时候点的这个在原生tabbar下反而很难做因为onTabItemTap的生命周期在部分平台上支持不完整。6.4 不想完全放弃原生tabBar试试混合方案写到这里肯定有人会问如果项目里已有大量原生tabBar页面不想大改能不能只在必要场景用自定义tabbar也可以。有一种混合方案pages.json里注册一个“无tabbar的首页”一旦检测到当前角色是guest就reLaunch到无tabbar的页面用自定义组件展示游客简化菜单一旦检测到已登录则reLaunch回原生tabBar的首页用原生tabbar展示完整菜单。相当于保留原生tabbar给高级角色游客用自定义菜单。这个方案的维护成本会低一些但体验上会有一种“割裂感”两种角色的底部栏长得不一样动画、高度、安全区适配都会有细微差别。我个人不推荐一个应用里混用两套tabbar但如果你的团队已经跑在原生tabbar上又临时被要求支持一个“极简模式”这算是一个折中方案。还有一点想提醒如果项目在安卓上架应用市场审核时对动态菜单这一套往往会比较敏感特别是“不同用户看到不同菜单”这个行为建议在提审材料里写明角色管理和访问控制逻辑避免审核人员把“游客看不到完整菜单”理解成功能缺失。这套自定义tabbar方案我前后迭代过两版。第一版偷懒用原生tabbar hideTabBar来隐藏结果就是各种闪现和兼容问题被测试追着打。后来狠下心全部换成自定义组件虽然多写了一百多行代码但整个底部导航的交互完全掌控在手里了。现在不管是加角标、加中间按钮还是调整菜单权限都只需要改配置或者组件内部逻辑再也不用去跟pages.json较劲。如果你正在经历类似的改造我的建议是先把角色菜单配置抽离出来再把页面跳转全部统一成reLaunch然后把TabBar组件做干净。这三步做完剩下的都是细节问题。希望这篇文章能让你少踩几个我踩过的坑。