多端登录,群魔乱舞 欢迎继续阅读《Taro 小程序开发大型实战》系列,前情回顾:
而在这一篇中,我们将实现微信和支付宝多端登录。如果你希望直接从这一篇开始,请运行以下命令:
git clone -b third-part https://github.com/tuture-dev/ultra-club.git cd ultra-club
与普通的 Web 应用相比,小程序能够在所在的平台实现一键登录,非常方便。这一步,我们也将实现多端登录(主要包括微信登录和支付宝登录)。之所以标题取为“群魔乱舞”,不仅受了“震惊”小编们的启发,也是因为当今各平台处理登录和鉴权的方式差异很大,很遗憾的是在 Taro 框架下我们依然需要踩很多“坑”才能真正实现“多端登录”。
准备工作 前提条件 在正式开始之前,我们希望你已经具备以下知识:
基本的 React 框架知识,可参考这篇文章 进行学习
对常用的 React Hooks (useState
、useEffect
)有所了解,后面图雀社区将推出 “一杯茶的时间,上手 React Hooks”,敬请期待!
除此之外,你还需要下载并安装 支付宝开发者工具 ,登录后创建自己的小程序 ID。
组件设计规划 这一节的代码很长,在正式开始之前我们先查看一下组件设计的规划,便于你对接下来我们要做的工作有清晰的了解。
可以看到“我的”页面整体拆分成了 Header
和 Footer
:
Header
包括 LoggedMine
(个人信息),如果在未登录状态下则还有 LoginButton
(普通登录按钮)、WeappLoginButton
(微信登录按钮,仅在微信小程序中出现)以及 AlipayLoginButton
(支付宝登录按钮,仅在支付宝小程序中出现)
Footer
则用来显示是否已登录的文字,在已登录的情况下会显示 Logout
(退出登录按钮)
配置 Babel 插件 从这一步开始,我们将首次开始写异步代码。本项目将采用流行的 async/await 来编写异步逻辑,因此我们配置一下相应的 Babel 插件:
npm install babel-plugin-transform-runtime --save-dev
然后在 config/index.js
中为 config.babel.plugins
添加相应的配置如下:
config/index.js 查看完整代码 'transform-decorators-legacy' , 'transform-class-properties' , 'transform-object-rest-spread' , [tuture-add] [ [tuture-add] 'transform-runtime' , [tuture-add] { [tuture-add] helpers: false , [tuture-add] polyfill: false , [tuture-add] regenerator: true , [tuture-add] moduleName: 'babel-runtime' , [tuture-add] }, [tuture-add] ], ], }, plugins: [],
各组件的实现 首先,我们来实现普通登录按钮 LoginButton
组件。创建 src/components/LoginButton
目录,在其中创建 index.js
,代码如下:
src/components/LoginButton/index.js 查看完整代码 import Taro from '@tarojs/taro' import { AtButton } from 'taro-ui' export default function LoginButton (props ) { return ( <AtButton type="primary" onClick={props.handleClick}> 普通登录 </AtButton> ) }
我们使用了 Taro UI 的 AtButton
组件,并定义了一个 handleClick
事件,后面在使用时会传入。
接着我们实现微信登录按钮 WeappLoginButton
。创建 src/components/WeappLoginButton
目录,在其中分别创建 index.js
和 index.scss
。index.js
代码如下:
src/components/WeappLoginButton/index.js 查看完整代码 import Taro, { useState } from '@tarojs/taro' import { Button } from '@tarojs/components' import './index.scss' export default function LoginButton (props ) { const [isLogin, setIsLogin] = useState(false ) async function onGetUserInfo (e ) { setIsLogin(true ) const { avatarUrl, nickName } = e.detail.userInfo await props.setLoginInfo(avatarUrl, nickName) setIsLogin(false ) } return ( <Button openType="getUserInfo" onGetUserInfo={onGetUserInfo} type="primary" className="login-button" loading={isLogin} > 微信登录 </Button> ) }
可以看到,微信登录按钮和之前的普通登录按钮多了很多东西:
添加了 isLogin
状态,用于表示是否在等待登录中,以及修改状态的 setIsLogin
函数
实现了 onGetUserInfo
async 函数,用于处理在用户点击登录按钮、获取到信息之后的逻辑。其中,我们将获取到的用户信息传入 props
中的 setLoginInfo
,从而修改整个应用的登录状态
添加了 openType
(微信开放能力)属性,这里我们输入的是 getUserInfo
(获取用户信息),欲查看所有支持的 open-type,请查看微信开放文档对应部分
添加了 onGetUserInfo
这个 handler,用于编写在获取到用户信息后的处理逻辑,这里就是传入刚刚实现的 onGetUserInfo
WeappLoginButton
的样式 index.scss
代码如下:
src/components/WeappLoginButton/index.scss 查看完整代码 .login-button { width : 100% ; margin-top : 40px ; margin-bottom : 40px ; }
让我们来实现支付宝登录按钮组件。创建 src/components/AlipayLoginButton
目录,在其中分别创建 index.js
和 index.scss
。index.js
代码如下:
src/components/AlipayLoginButton/index.js 查看完整代码 import Taro, { useState } from '@tarojs/taro' import { Button } from '@tarojs/components' import './index.scss' export default function LoginButton (props ) { const [isLogin, setIsLogin] = useState(false ) async function onGetAuthorize (res ) { setIsLogin(true ) try { let userInfo = await Taro.getOpenUserInfo() userInfo = JSON .parse(userInfo.response).response const { avatar, nickName } = userInfo await props.setLoginInfo(avatar, nickName) } catch (err) { console .log('onGetAuthorize ERR: ' , err) } setIsLogin(false ) } return ( <Button openType="getAuthorize" scope="userInfo" onGetAuthorize={onGetAuthorize} type="primary" className="login-button" loading={isLogin} > 支付宝登录 </Button> ) }
可以看到,内容与之前的微信登录按钮基本相似,但是有以下差别:
实现 onGetAuthorize
回调函数。与之前微信的回调函数不同,这里我们要调用 Taro.getOpenUserInfo
手动获取用户基础信息(实际上调用的是支付宝开放平台 my.getOpenUserInfo )
Button
组件的 openType
(支付宝开放能力)设置成 getAuthorize
(小程序授权)
在设定开放能力为 getAuthorize
时,需要添加 scope
属性为 userInfo
,让用户可以授权小程序获取支付宝会员的基础信息(另一个有效值是 phoneNumber
,用于获取手机号码)
传入 onGetAuthorize
回调函数
提示
关于支付宝小程序登录按钮的细节,可以查看官方文档 。
样式文件 index.scss
的代码如下:
src/components/AlipayLoginButton/index.scss 查看完整代码 .login-button { width : 100% ; margin-top : 40px ; }
实现 LoggedMine 接着我们实现已经登录状态下的 LoggedMine
组件。创建 src/components/LoggedMine
目录,在其中分别创建 index.jsx
和 index.scss
。index.jsx
代码如下:
src/components/LoggedMine/index.jsx 查看完整代码 import Taro from '@tarojs/taro' import { View, Image } from '@tarojs/components' import PropTypes from 'prop-types' import './index.scss' import avatar from '../../images/avatar.png' export default function LoggedMine (props ) { const { userInfo = {} } = props function onImageClick ( ) { Taro.previewImage({ urls: [userInfo.avatar], }) } return ( <View className="logged-mine" > <Image src={userInfo.avatar ? userInfo.avatar : avatar} className="mine-avatar" onClick={onImageClick} /> <View className="mine-nickName" > {userInfo.nickName ? userInfo.nickName : '图雀酱' } </View> <View className="mine-username">{userInfo.username}</ View> </View> ) } LoggedMine.propTypes = { avatar: PropTypes.string, nickName: PropTypes.string, username: PropTypes.string, }
这里我们添加了点击头像可以预览的功能,可以通过 Taro.previewImage
函数 实现。
LoggedMine
组件的样式文件如下:
src/components/LoggedMine/index.scss 查看完整代码 .logged-mine { display : flex; flex-direction : column; align-items : center; } .mine-avatar { width : 200px ; height : 200px ; border-radius : 50% ; } .mine-nickName { font-size : 40 ; margin-top : 20px ; } .mine-username { font-size : 32px ; margin-top : 16px ; color : #777 ; }
在所有的“小零件”全部实现后,我们就实现整个登录界面的 Header
部分。创建 src/components/Header
目录,在其中分别创建 index.js
和 index.scss
。index.js
代码如下:
src/components/Header/index.js 查看完整代码 import Taro from '@tarojs/taro' import { View } from '@tarojs/components' import { AtMessage } from 'taro-ui' import LoggedMine from '../LoggedMine' import LoginButton from '../LoginButton' import WeappLoginButton from '../WeappLoginButton' import AlipayLoginButton from '../AlipayLoginButton' import './index.scss' export default function Header (props ) { const isWeapp = Taro.getEnv() === Taro.ENV_TYPE.WEAPP const isAlipay = Taro.getEnv() === Taro.ENV_TYPE.ALIPAY return ( <View className="user-box" > <AtMessage /> <LoggedMine userInfo={props.userInfo} /> {!props.isLogged && ( <View className="login-button-box" > <LoginButton handleClick={props.handleClick} /> {isWeapp && <WeappLoginButton setLoginInfo ={props.setLoginInfo} /> } {isAlipay && <AlipayLoginButton setLoginInfo ={props.setLoginInfo} /> } </View> )} </ View> ) }
可以看到,我们根据 Taro.ENV_TYPE
查询当前所在的平台(微信、支付宝或其他),然后确定是否显示相应平台的登录按钮。
提示
你也许发现了,setLoginInfo
还是要等待父组件的传入。虽然 Hooks 简化了状态的定义和更新方式,但是却没有简化跨组件修改状态的逻辑。在接下来的一步,我们将用 Redux 进行简化。
Header
组件的样式代码如下:
src/components/Header/index.scss 查看完整代码 .user-box { display : flex; flex-direction : column; align-items : center; justify-content : flex-start; } .login-button-box { margin-top : 60px ; width : 100% ; }
接着我们实现用于普通登录的 LoginForm
组件。由于本系列教程的目标是讲解 Taro,因此这里简化了注册/登录的流程,用户可以直接输入用户名并上传头像进行注册/登录,无需设置密码和其他验证过程。创建 src/components/LoginForm
目录,在其中分别创建 index.jsx
和 index.scss
。index.jsx
代码如下:
src/components/LoginForm/index.jsx 查看完整代码 import Taro, { useState } from '@tarojs/taro' import { View, Form } from '@tarojs/components' import { AtButton, AtImagePicker } from 'taro-ui' import './index.scss' export default function LoginForm (props ) { const [showAddBtn, setShowAddBtn] = useState(true ) function onChange (files ) { if (files.length > 0 ) { setShowAddBtn(false ) } props.handleFilesSelect(files) } function onImageClick ( ) { Taro.previewImage({ urls: [props.files[0 ].url], }) } return ( <View className="post-form" > <Form onSubmit={props.handleSubmit}> <View className="login-box" > <View className="avatar-selector" > <AtImagePicker length={1 } mode="scaleToFill" count={1 } files={props.files} showAddBtn={showAddBtn} onImageClick={onImageClick} onChange={onChange} /> </View> <Input className="input-nickName" type="text" placeholder="点击输入昵称" value={props.formNickName} onInput={props.handleNickNameInput} / > <AtButton formType="submit" type="primary" > 登录 </AtButton> </ View> </Form> </ View> ) }
这里我们使用 Taro UI 的 ImagePicker 图片选择器组件 ,让用户能够选择图片进行上传。AtImagePicker
最重要的属性就是 onChange
回调函数,这里我们通过父组件传进来的 handleFilesSelect
函数来搞定。
LoginForm
组件的样式代码如下:
src/components/LoginForm/index.scss 查看完整代码 .post-form { margin : 0 30px ; padding : 30px ; } .input-nickName { border : 1px solid #eee ; padding : 10px ; font-size : medium; width : 100% ; margin-top : 40px ; margin-bottom : 40px ; } .avatar-selector { width : 200px ; margin : 0 auto; }
实现 Logout 在登录之后,我们还需要退出登录的按钮。创建 src/components/Logout/index.js
文件,代码如下:
src/components/Logout/index.js 查看完整代码 import Taro from '@tarojs/taro' import { AtButton } from 'taro-ui' export default function LoginButton (props ) { return ( <AtButton type="secondary" full loading={props.loading} onClick={props.handleLogout} > 退出登录 </AtButton> ) }
所有的子组件全部实现之后,我们就来实现 Footer
组件。创建 src/components/Footer
目录,在其中分别创建 index.jsx
和 index.scss
。index.jsx
代码如下:
src/components/Footer/index.js 查看完整代码 import Taro, { useState } from '@tarojs/taro' import { View } from '@tarojs/components' import { AtFloatLayout } from 'taro-ui' import Logout from '../Logout' import LoginForm from '../LoginForm' import './index.scss' export default function Footer (props ) { const [formNickName, setFormNickName] = useState('' ) const [files, setFiles] = useState([]) async function handleSubmit (e ) { e.preventDefault() if (!formNickName || !files.length) { Taro.atMessage({ type: 'error' , message: '您还有内容没有填写!' , }) return } Taro.atMessage({ type: 'success' , message: '恭喜您,登录成功!' , }) const userInfo = { avatar : files[0 ].url, nickName : formNickName } await props.handleSubmit(userInfo) setFiles([]) setFormNickName('' ) } return ( <View className="mine-footer" > {props.isLogged && ( <Logout loading={props.isLogout} handleLogout={props.handleLogout} /> )} <View className="tuture-motto" > {props.isLogged ? 'From 图雀社区 with Love ❤' : '您还未登录' } </View> <AtFloatLayout isOpened={props.isOpened} title="登录" onClose={() => props.handleSetIsOpened(false)} > <LoginForm formNickName={formNickName} files={files} handleSubmit={e => handleSubmit(e)} handleNickNameInput={e => setFormNickName(e.target.value)} handleFilesSelect={files => setFiles(files)} / > </AtFloatLayout> </ View> ) }
Footer
组件的样式文件代码如下:
src/components/Footer/index.scss 查看完整代码 .mine-footer { font-size : 28px ; color : #777 ; margin-bottom : 20px ; } .tuture-motto { margin-top : 40px ; text-align : center; }
所有小组件都搞定之后,我们在 src/components
中只需暴露出 Header
和 Footer
。修改 src/components/index.jsx
,代码如下:
src/components/index.jsx 查看完整代码 import PostCard from './PostCard' import PostForm from './PostForm' [tuture-add]import Footer from './Footer' [tuture-add]import Header from './Header' [tuture-del]export { PostCard, PostForm } [tuture-add]export { PostCard, PostForm, Footer, Header }
更新“我的”页面 是时候用上写好的 Header
和 Footer
组件了,但在此之前,我们先来讲一下我们需要用到的 useEffect
Hooks。
useEffect Hooks useEffect
Hooks 是用来替代原 React 的生命周期钩子函数的,我们可以在里面发起一些 “副作用” 操作,比如异步获取后端数据、设置定时器或是进行 DOM 操作等:
import React, { useState, useEffect } from 'react' ;function Example ( ) { const [count, setCount] = useState(0 ); useEffect(() => { document .title = `{% raw %}你点击了 ${count} 次{% endraw %}` ; }); return ( <div> <p>你点击了 {count} 次</p> <button onClick={() => setCount(count + 1)}> 点我 </ button> </div> ); }
上面的对 document
标题的修改是具有副作用的操作,在之前的 React 应用中,我们通常会这么写:
class Example extends React .Component { constructor (props) { super (props); this .state = { count: 0 }; } componentDidMount() { document .title = `{% raw %}你点击了 ${this .state.count} 次{% endraw %}` ; } componentDidUpdate() { document .title = `{% raw %}你点击了 ${this .state.count} 次{% endraw %}` ; } render() { return ( <div> <p>你点击了 {this .state.count} 次</p> <button onClick={() => this.setState({ count: this.state.count + 1 })}> 点我 </ button> </div> ); } }
如果你想了解 useEffect
具体的详情,可以去查看 React 的官方文档 。
做的好!了解了 useEffect
Hooks 的概念之后,我们马上来更新“我的”页面组件 src/pages/mine/mine.jsx
,代码如下:
src/pages/mine/mine.jsx 查看完整代码 [tuture-del]import Taro from '@tarojs/taro' [tuture-del]import { View, Image } from '@tarojs/components' [tuture-add]import Taro, { useState, useEffect } from '@tarojs/taro' [tuture-add]import { View } from '@tarojs/components' [tuture-add]import { Header, Footer } from '../../components' import './mine.scss' [tuture-del]import avatar from '../../images/avatar.png' export default function Mine ( ) {[tuture-add] const [nickName, setNickName] = useState('' ) [tuture-add] const [avatar, setAvatar] = useState('' ) [tuture-add] const [isOpened, setIsOpened] = useState(false ) [tuture-add] const [isLogout, setIsLogout] = useState(false ) [tuture-add] [tuture-add] [tuture-add] const isLogged = !!nickName [tuture-add] [tuture-add] useEffect(() => { [tuture-add] async function getStorage ( ) { [tuture-add] try { [tuture-add] const { data } = await Taro.getStorage({ key : 'userInfo' }) [tuture-add] [tuture-add] const { nickName, avatar } = data [tuture-add] setAvatar(avatar) [tuture-add] setNickName(nickName) [tuture-add] } catch (err) { [tuture-add] console .log('getStorage ERR: ' , err) [tuture-add] } [tuture-add] } [tuture-add] [tuture-add] getStorage() [tuture-add] }) [tuture-add] [tuture-add] async function setLoginInfo (avatar, nickName ) { [tuture-add] setAvatar(avatar) [tuture-add] setNickName(nickName) [tuture-add] [tuture-add] try { [tuture-add] await Taro.setStorage({ [tuture-add] key: 'userInfo' , [tuture-add] data: { avatar, nickName }, [tuture-add] }) [tuture-add] } catch (err) { [tuture-add] console .log('setStorage ERR: ' , err) [tuture-add] } [tuture-add] } [tuture-add] [tuture-add] async function handleLogout ( ) { [tuture-add] setIsLogout(true ) [tuture-add] [tuture-add] try { [tuture-add] await Taro.removeStorage({ key : 'userInfo' }) [tuture-add] [tuture-add] setAvatar('' ) [tuture-add] setNickName('' ) [tuture-add] } catch (err) { [tuture-add] console .log('removeStorage ERR: ' , err) [tuture-add] } [tuture-add] [tuture-add] setIsLogout(false ) [tuture-add] } [tuture-add] [tuture-add] function handleSetIsOpened (isOpened ) { [tuture-add] setIsOpened(isOpened) [tuture-add] } [tuture-add] [tuture-add] function handleClick ( ) { [tuture-add] handleSetIsOpened(true ) [tuture-add] } [tuture-add] [tuture-add] async function handleSubmit (userInfo ) { [tuture-add] [tuture-add] await Taro.setStorage({ key : 'userInfo' , data : userInfo }) [tuture-add] [tuture-add] [tuture-add] setAvatar(userInfo.avatar) [tuture-add] setNickName(userInfo.nickName) [tuture-add] [tuture-add] [tuture-add] setIsOpened(false ) [tuture-add] } [tuture-add] return ( <View className="mine" > [tuture-del] <View> [tuture-del] <Image src={avatar} className="mine-avatar" /> [tuture-del] <View className="mine-nickName" >图雀酱</View> [tuture-del] <View className="mine-username">tuture</ View>[tuture-del] </View> [tuture-del] <View className="mine-footer">From 图雀社区 with Love ❤</ View>[tuture-add] <Header [tuture-add] isLogged={isLogged} [tuture-add] userInfo={{ avatar, nickName }} [tuture-add] handleClick={handleClick} [tuture-add] setLoginInfo={setLoginInfo} [tuture-add] /> [tuture-add] <Footer [tuture-add] isLogged={isLogged} [tuture-add] isOpened={isOpened} [tuture-add] isLogout={isLogout} [tuture-add] handleLogout={handleLogout} [tuture-add] handleSetIsOpened={handleSetIsOpened} [tuture-add] handleSubmit={handleSubmit} [tuture-add] /> </View> ) } / / ...
可以看到,我们做了这么些工作:
使用 useState
创建了四个状态:用户有关信息(nickName
和 avatar
),登录弹出层是否打开(isOpened
),是否登录成功(isLogged
),以及相应的更新函数
通过 useEffect
Hook 尝试从本地缓存中获取用户信息(Taro.getStorage ),并用来更新 nickName
和 avatar
状态
实现了久违的 setLoginInfo
函数,其中我们不仅更新了 nickName
和 avatar
的状态,还把用户数据存入本地缓存(Taro.getStorage ),确保下次打开时保持登录状态
实现了同样久违的 handleLogout
函数,其中不仅更新了相关状态,还去掉了本地缓存中的数据(Taro.removeStorage )
实现了用于处理普通登录的 handleSubmit
函数,内容基本上与 setLoginInfo
一致
在返回 JSX 代码时渲染 Header
和 Footer
组件,传入相应的状态和回调函数
调整 Mine
组件的样式 src/pages/mine/mine.scss
代码如下:
src/pages/mine/mine.scss 查看完整代码 .mine { margin : 30px ; [tuture-del] border : 1px solid #ddd ;[tuture-del] text-align : center; height : 90vh ; [tuture-del] padding-top : 40px ;[tuture-add] padding : 40px 40px 0 ; display : flex; flex-direction : column; [tuture-del] align-items : center; justify-content : space-between; } [tuture-del] [tuture-del] .mine-avatar {[tuture-del] width : 200px ;[tuture-del] height : 200px ;[tuture-del] border-radius : 50% ;[tuture-del] }[tuture-del] [tuture-del] .mine-nickName {[tuture-del] font-size : 40 ;[tuture-del] margin-top : 20px ;[tuture-del] }[tuture-del] [tuture-del] .mine-username {[tuture-del] font-size : 32px ;[tuture-del] margin-top : 16px ;[tuture-del] color : #777 ;[tuture-del] }[tuture-del] [tuture-del] .mine-footer {[tuture-del] font-size : 28px ;[tuture-del] color : #777 ;[tuture-del] margin-bottom : 20px ;[tuture-del] }
最后在 src/app.scss
中引入相应的 Taro UI 组件的样式:
src/app.scss 查看完整代码 ... @import '~taro-ui/dist/style/components/textarea.scss' ;@import '~taro-ui/dist/style/components/message.scss' ;@import '~taro-ui/dist/style/components/avatar.scss' ;[tuture-add] @import '~taro-ui/dist/style/components/image-picker.scss' ;[tuture-add] @import '~taro-ui/dist/style/components/icon.scss' ;
查看效果 敲了这么多代码,终于又到了神圣的验收环节。首先是普通登录:
而微信和支付宝登录,点击之后就会直接以登录开发者工具所用的帐号登录了。下面贴出我微信和支付宝登录后的界面展示:
登录后点击下方的“退出登录”按钮,就会将当前登录帐户注销哦。
至此,《Taro 多端小程序开发大型实战》第三篇也就结束啦。在接下来的第四篇 中,我们将逐步用 Redux 来重构业务数据流,让我们现在略显臃肿的状态管理变得清晰可控。