0%

Taro 小程序开发大型实战(三):实现微信和支付宝多端登录

@tuture-dev

查看代码

多端登录,群魔乱舞

欢迎继续阅读《Taro 小程序开发大型实战》系列,前情回顾:

而在这一篇中,我们将实现微信和支付宝多端登录。如果你希望直接从这一篇开始,请运行以下命令:

git clone -b third-part https://github.com/tuture-dev/ultra-club.git
cd ultra-club

与普通的 Web 应用相比,小程序能够在所在的平台实现一键登录,非常方便。这一步,我们也将实现多端登录(主要包括微信登录和支付宝登录)。之所以标题取为“群魔乱舞”,不仅受了“震惊”小编们的启发,也是因为当今各平台处理登录和鉴权的方式差异很大,很遗憾的是在 Taro 框架下我们依然需要踩很多“坑”才能真正实现“多端登录”。

准备工作

前提条件

在正式开始之前,我们希望你已经具备以下知识:

  • 基本的 React 框架知识,可参考这篇文章进行学习
  • 对常用的 React Hooks (useStateuseEffect)有所了解,后面图雀社区将推出 “一杯茶的时间,上手 React Hooks”,敬请期待!

除此之外,你还需要下载并安装支付宝开发者工具,登录后创建自己的小程序 ID。

组件设计规划

这一节的代码很长,在正式开始之前我们先查看一下组件设计的规划,便于你对接下来我们要做的工作有清晰的了解。

可以看到“我的”页面整体拆分成了 HeaderFooter

  • Header 包括 LoggedMine(个人信息),如果在未登录状态下则还有 LoginButton(普通登录按钮)、WeappLoginButton(微信登录按钮,仅在微信小程序中出现)以及 AlipayLoginButton(支付宝登录按钮,仅在支付宝小程序中出现)
  • Footer 则用来显示是否已登录的文字,在已登录的情况下会显示 Logout(退出登录按钮)

配置 Babel 插件

从这一步开始,我们将首次开始写异步代码。本项目将采用流行的 async/await 来编写异步逻辑,因此我们配置一下相应的 Babel 插件:

npm install babel-plugin-transform-runtime --save-dev
# yarn add babel-plugin-transform-runtime -D

然后在 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

首先,我们来实现普通登录按钮 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

接着我们实现微信登录按钮 WeappLoginButton。创建 src/components/WeappLoginButton 目录,在其中分别创建 index.jsindex.scssindex.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;
}

实现 AlipayLoginButton

让我们来实现支付宝登录按钮组件。创建 src/components/AlipayLoginButton 目录,在其中分别创建 index.jsindex.scssindex.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.jsxindex.scssindex.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 组件

在所有的“小零件”全部实现后,我们就实现整个登录界面的 Header 部分。创建 src/components/Header 目录,在其中分别创建 index.jsindex.scssindex.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

接着我们实现用于普通登录的 LoginForm 组件。由于本系列教程的目标是讲解 Taro,因此这里简化了注册/登录的流程,用户可以直接输入用户名并上传头像进行注册/登录,无需设置密码和其他验证过程。创建 src/components/LoginForm 目录,在其中分别创建 index.jsxindex.scssindex.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.jsxindex.scssindex.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) {
// Login Form 登录数据
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: '恭喜您,登录成功!',
})

// 缓存在 storage 里面
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 中只需暴露出 HeaderFooter。修改 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 }

更新“我的”页面

是时候用上写好的 HeaderFooter 组件了,但在此之前,我们先来讲一下我们需要用到的 useEffect Hooks。

useEffect Hooks

useEffect Hooks 是用来替代原 React 的生命周期钩子函数的,我们可以在里面发起一些 “副作用” 操作,比如异步获取后端数据、设置定时器或是进行 DOM 操作等:

import React, { useState, useEffect } from 'react';

function Example() {
const [count, setCount] = useState(0);

// 和 componentDidMount 以及 componentDidUpdate 类似:
useEffect(() => {
// 使用浏览器 API 更新 document 的标题
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] // 缓存在 storage 里面
[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 创建了四个状态:用户有关信息(nickNameavatar),登录弹出层是否打开(isOpened),是否登录成功(isLogged),以及相应的更新函数
  • 通过 useEffect Hook 尝试从本地缓存中获取用户信息(Taro.getStorage),并用来更新 nickNameavatar 状态
  • 实现了久违的 setLoginInfo 函数,其中我们不仅更新了 nickNameavatar 的状态,还把用户数据存入本地缓存(Taro.getStorage),确保下次打开时保持登录状态
  • 实现了同样久违的 handleLogout 函数,其中不仅更新了相关状态,还去掉了本地缓存中的数据(Taro.removeStorage
  • 实现了用于处理普通登录的 handleSubmit 函数,内容基本上与 setLoginInfo 一致
  • 在返回 JSX 代码时渲染 HeaderFooter 组件,传入相应的状态和回调函数

调整 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 来重构业务数据流,让我们现在略显臃肿的状态管理变得清晰可控。

图雀社区 微信公众号

扫一扫关注上方公众号,拉学习群和答疑解惑

  • 本文作者: 图雀社区
  • 本文链接: https://tuture.co/2019/12/25/5e10118/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!