icejs-todos-miniprogram
如何同时开发小程序+中后台应用(feat: icejs) - 小程序篇
知乎:使用 React + icejs 开发一个完整的 Todo 应用 - 小程序篇
语雀:如何同时开发小程序+中后台应用(feat: icejs) - 小程序篇
icejs 主要应用场景为开发中后台应用。但 icejs@1.7.0 版本开始支持小程序开发。如果你想使用 React 同时开发中后台应用和小程序,那么 icejs 即可满足你。使用同一套技术体系,减少技术切换成本,提高研发效率。
介绍
本文将演示如何使用 icejs 构建 Todo 小程序 + 后台管理系统,同时包括相应服务端。
Todo 应用的功能或需求为:
- 小程序端:展示 Todo 列表,支持增删改查,以及同步数据到服务端。
- 后台管理系统:小程序用户信息和增删改查的管理系统。
整体的技术栈设计如下:
- *小程序 *
- icejs 框架
- universal-request 数据请求
- 后台管理系统
- icejs 框架
- icejs build-plugin-ice-auth 插件,权限管理
- fusion design UI 组件库
- 服务端 + 数据库
- eggjs 服务端框架
- egg-sequelize + mysql2 用于 eggjs 连接 MySQL 数据库
- MySQL 数据库
- uuid 唯一 id 生成
因篇幅较长,如何同时开发小程序+中后台应用(feat: icejs)将分为三篇分别介绍。
- 小程序篇(本文)
使用 icejs 开发 Todo 小程序。
- 后台管理系统篇
使用 icejs 开发 Todo 小程序后台管理系统。
- 服务端篇
搭建服务 Todo 小程序及后台管理系统的服务端。
小程序
项目代码见:miniprogram-materials/scaffolds/todos
小程序开发基于icejs,详细内容见 icejs 小程序开发文档
项目初始化
创建文件夹存放代码
$ mkdir todos && cd todos
基于 icejs 小程序 JavaScript 模板初始化项目
$ npm init ice . # 在当前目录下初始项目
选择 JavaScript 小程序模板即 Lightweight JavaScript template with miniapp Program
启动项目
$ npm install && npm run start
# 从微信开发者工具中导入构建完成后的产物可以看到项目正常运行
# 构建产物位置位于 ./build 目录中。./build/miniapp 为支付宝小程序;./build/wechat-miniprogram 为微信小程序
使用微信开发者工具管理小程序
导入构建的小程序包
此处 AppID 应填写自己所申请的小程序 AppID 或使用 测试号
开发者工具中小程序编译成功
项目目录结构为:
.
├── .ice/ # 运行时生成的临时目录
├── build/ # 构建产物目录
├── src/ # 源码目录
│ ├── components # 应用的公共组件
│ │ └── Logo
│ │ ├── index.module.less # Logo 组件的样式文件
│ │ └── index.jsx # Logo 组件 JSX 源码
│ └── pages # 页面
│ │ └── Home # home 页面
│ │ └── index.jsx
│ ├── app.js # 应用入口文件
│ └── app.json # 应用配置,包括路由配置,小程序 window 配置等
├── README.md # 项目说明
├── build.json # 项目构建配置
├── package.json
└── tsconfig.json
页面编写
页面编写与使用 React 开发基本一致。
对于小程序中的生命周期函数,可使用 usePageShow
、usePageHide
或者 withPageLifeCycle
等方法进行监听。详细文档见页面配置#生命周期。
Todos 列表页
创建 src/pages/todos 编写 UI
import React, { useState } from 'react';
import { usePageShow } from 'ice';
import AddButton from '@/components/add-button'; // 组件:添加新 Todo按钮
import logo from '@/public/logo.svg';
import styles from './index.module.scss';
const Todos = () => {
// state
const [userInfo, setUserInfo] = useState({});
const [todos, setTodos] = useState([]);
// handlers
const onTodoChange = async id => {
let changedContent = {};
const changedTodos = todos.map(todo => {
const { id: curId } = todo;
const { completed } = todo.content;
if (id === curId) {
changedContent = {
...todo.content,
completed: id === curId ? !completed : completed
};
}
return {
...todo,
content: {
...todo.content,
completed: id === curId ? !completed : completed
}
};
});
setTodos(changedTodos);
};
// lifecycle function
usePageShow(async () => {
const defaultTodos = [
{
content: { text: 'Learning Javascript', completed: true },
id: 0
},
{
content: { text: 'Learning ES2016', completed: true },
id: 1
},
{
content: { text: 'Learning 小程序', completed: false },
id: 2
},
]
// 暂时使用默认 Todos 测试 UI
setTodos(defaultTodos);
})
return (
<div className={styles['page-todos']}>
<div className={styles.user}>
<button type='button' className={styles['login-button']} >
<div style={{display: 'flex', flexDirection: 'column'}}>
<img className={styles.avatar} src={userInfo.avatarUrl ? userInfo.avatarUrl : logo} alt="用户头像" />
<span className={styles.nickname}>{userInfo.nickName ? `${userInfo.nickName}'s` : 'My' } Todo List</span>
</div>
</button>
</div>
<div className={styles['todo-items']}>
<div className={styles['todo-items-group']}>
{
todos.map(todo => (
<div style={{position: 'relative'}} key={todo.id}>
<div
className={`${styles['todo-item']} ${todo.content.completed ? styles.checked : ''}`}
onClick={() => onTodoChange(todo.id)}
>
<checkbox className={styles['todo-item-checkbox']} checked={todo.content.completed} />
<span className={styles['todo-item-text']}>{todo.content.text}</span>
</div>
<div
className={styles['close-wrapper']}
>
<div className={styles.close}/>
</div>
</div>
))
}
</div>
</div>
<div className={styles['todo-footer']}>
<AddButton text="Add Todo" />
</div>
</div>
);
};
export default Todos;
添加样式
// src/pages/todos/index.module.scss
page {
flex: 1;
display: flex;
background: #323239;
font-family: "pingFang SC" "pingFang";
}
body {
flex: 1;
display: flex;
background: #323239;
font-family: "pingFang SC" "pingFang";
}
.page-todos {
font-family: "pingFang SC" "pingFang";
display: flex;
flex-direction: column;
width: 750rpx;
max-height: 100vh;
}
.user {
display: flex;
flex-shrink: 0;
padding: 30px;
color: #FFF;
flex-direction: column;
align-items: center;
}
.login-button {
display: inline-block;
background: none;
border: none;
width: auto;
height: auto;
}
.login-button:after{
content: none;
}
.avatar {
width: 130rpx;
height: 130rpx;
border-radius: 50%;
background-color: #FFF;
align-self: center;
}
.nickname {
padding-top: 40rpx;
text-align: center;
font-size: 40rpx;
font-weight: 100;
color: #FFF;
}
.todo-items {
flex-grow: 1;
font-size: 34rpx;
padding: 0 120rpx;
color: #0EFFD6;
overflow: auto;
}
.todo-items-group {
display: flex;
flex-direction: column;
}
.todo-item {
position: relative;
margin-bottom: 50rpx;
padding-left:80rpx;
line-height: 70rpx;
height: 80rpx;
box-sizing: border-box;
border: 2px solid rgb(14, 255, 214);
border-radius: 100rpx;
overflow: hidden;
text-overflow: ellipsis;
/* white-space:nowrap; */
transition: border 0.2s;
}
.todo-item:last-child {
margin-bottom: 0;
}
.todo-item::before {
content: '';
position: absolute;
left: 12rpx;
margin-right: 20rpx;
width: 45rpx;
height: 45rpx;
background-color: rgba(14, 222, 255, 0.3);
border-radius: 50%;
top: 50%;
transform: translateY(-50%);
transition: background-color 0.2s;
}
.todo-item::after {
content: '';
position: absolute;
left: 29rpx;
width: 8rpx;
height: 18rpx;
top: 50%;
transform: translateY(-60%) rotate(38deg);
border: 4rpx solid #FFF;
border-width: 0 4rpx 4rpx 0;
opacity: 0;
transition: opacity 0.2s;
}
.todo-item-checkbox {
display: none;
}
.checked .todo-item-text {
text-decoration: line-through;
color: #1AA0B8;
}
.checked.todo-item {
border: 2px solid rgba(14, 222, 255, 0.2);
}
.checked.todo-item::before {
background-color: rgba(14, 222, 255, 0.2);
}
.checked.todo-item::after {
opacity: 1;
}
.todo-item-operation {
display: inline-block;
background: none;
color: #FFF;
border: none;
}
.todo-item-operation:after {
content: none;
}
.close-wrapper {
box-sizing: border-box;
position: absolute;
top: 0;
right: 0;
width: 80rpx;
height: 80rpx;
padding-top: 20rpx;
padding-left: 20rpx;
}
.close {
box-sizing: border-box;
position: relative;
top: 0;
right: 0;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background-color: rgba(14, 222, 255, 0.3);
}
.close::before {
position: absolute;
content: ' ';
background-color: #2c2c2c;
width: 8rpx;
height: 30rpx;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(45deg);
}
.close::after {
position: absolute;
content: ' ';
background-color: #2c2c2c;
width: 8rpx;
height: 30rpx;
border-radius: 4rpx;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
}
.todo-footer {
flex-shrink: 0;
padding: 50rpx 0 100rpx;
font-size: 48rpx;
font-weight: 200;
text-align: center;
}
编写所需组件 add-button
// src/components/add-button/index.jsx
import React from 'react';
import styles from './index.module.scss';
function AddButton (props) {
const { text, onClickMe } = props;
return (
<button type='button' className={styles['add-button']} onClick={onClickMe}>
<span className={styles['add-icon']}>+</span>
<span>{text}</span>
</button>
);
}
export default AddButton;
为组件添加样式
// src/components/add-button/index.module.scss
.add-button {
display: inline-block;
background: none;
color: #FFF;
border: none;
width: 300rpx;
}
.add-button:after {
content: none;
}
.add-icon {
font-size: 52rpx;
color: #00FFD6;
margin-right: 10rpx;
}
添加 logo 图片
src/public/logo.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
<title>React Logo</title>
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
<g stroke="#61dafb" stroke-width="1" fill="none">
<ellipse rx="11" ry="4.2"/>
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
</g>
</svg>
添加路由
编辑 src/app.json
{
"routes": [ // 页面路由数组
{
"path": "/todos", // /todos 路由
"source": "pages/todos/index" // 实际 React 组件,即上面所写组件
},
]
}
构建小程序
$ npm run start # 构建小程序
# 将构建产物 ./build/wechat-miniprogram 导入至微信开发者工具中
小程序开发者工具进行编译,实际效果为:
切换 Todo 完成状态
Todos 列表页面样式完成。可切换 Todo 完成情况。
添加 Todo 页
编写 UI
// src/pages/add-todo/index.jsx
import React, { useState } from 'react';
import AddButton from '@/components/add-button';
import styles from './index.module.scss';
const AddTodo = () => {
// state
const [value, setValue] = useState('');
// handlers
const onChange = (e) => {
setValue(e.target.value);
};
// 省略添加 Todo 逻辑
const add = async () => {};
return (
<div className={styles['page-add-todo']}>
<div className={styles['add-todo']}>
<input
className={styles['add-todo-input']}
placeholder="What needs to be done?"
value={value}
onChange={() => {}}
onInput={onChange} />
</div>
<div className={styles['todo-footer']}>
<AddButton text="Add Todo" onClickMe={add}/>
</div>
</div>
);
};
export default AddTodo;
添加样式
// src/pages/add-todo/index.module.scss
page {
background: #323239;
font-family: "pingFang SC" "pingFang";
}
.page-add-todo {
font-family: "pingFang SC" "pingFang";
display: flex;
flex: 1;
flex-direction: column;
width: 750rpx;
}
.add-todo {
padding: 40px;
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
}
.add-todo-input {
display: block;
font-size: 50rpx;
font-weight: 100;
padding: 5px 5px;
background: none;
border:none;
border-bottom: 1px solid #DFDFDF;
color: #0EFFD6;
width: 100%;
}
.todo-footer {
padding: 50rpx 0 100rpx;
font-size: 48rpx;
font-weight: 200;
text-align: center;
}
配置路由
{
"routes": [
{
"path": "/add-todo",
"source": "pages/add-todo/index"
},
{
"path": "/todos",
"source": "pages/todos/index"
}
]
}
微信小程序开发者工具中执行自定义编译
- 添加编译模式
- 配置启动页面为 pages/add-todo/index
编译后可看到添加 Todo 页面效果
)
此时的目录结构为:
原有 Logo 组件及 Home 页面未使用可删除。
.
├── public
│ └── index.html
├── src
│ ├── components
│ │ └── add-button // 新增组件 add-button
│ ├── pages
│ │ ├── add-todo // 新增页面 add-todo
│ │ └── todos // 新增页面 todos
│ ├── public
│ │ └── logo.svg // 新增 logo 图片
│ ├── app.js
│ └── app.json
├── .eslintrc.js
├── .gitignore
├── README.md
├── build.json
├── mini.project.json
├── package-lock.json
├── package.json
└── tsconfig.json
项目配置
与编写 Web 应用不同的是,需要为小程序增加一些配置。如页面路由、tabBar、页面标题等小程序特有属性。
本程序配置如下:
// app.json
{
"routes": [ // 页面路由
{
"path": "/todos",
"source": "pages/todos/index" // React 组件
},
{
"path": "/add-todo",
"source": "pages/add-todo/index"
}
],
"window": { // 页面标题、颜色
"title": "Todo App",
"titleBarColor": "#323239"
}
}
可看到头部颜色及标题发生变化
数据请求
通过与后台 API 接口交互将 Todos 同步到远程数据库中。需要用到 universal-request,该库对数据请求进行了封装,使得用户无需关心 web端、微信小程序、支付宝小程序等多平台差异。一套代码、多处运行。
以获取某用户的 Todos 为例
相关接口见后文服务端
安装 universal-request
$ npm install universal-request
定义数据请求 service
// src/services/todos.js
import request from 'universal-request';
// 此处为 mock 接口,仅包含查询固定 todos 列表。其他功能如对小程序的增删改查需要服务端
const URL_PREFIX = 'https://easy-mock.bookset.io/mock/5f4f05642ff5d50508b3d21b/todos_mock'
export default {
// 根据用户 openId 获取对应 todo 列表
async list ({ openId }) {
let todos = [];
try {
// openId 未使用,可随意填写
const URL = `${URL_PREFIX}/api/mp/todos?openId=${openId}`;
const res = await request({
url: URL
});
todos = res.data.data.todos;
} catch (err) {
console.error(err);
}
return todos;
}
}
Todos 组件中调用该数据请求 service,移除默认 Todos
// src/pages/todos/index.jsx
+ import todosService from '@/services/todos'; // 引入 todos service
const Todos = () => {
// ...
// lifecycle function
// usePageShow 函数修改如下
usePageShow(async () => {
// 通过数据请求获取 Todos 数据
const todos = await todosService.list(1); // 1 为 openId,暂时未使用可随意填写
setTodos(todos);
})
return (
// 渲染 todos
);
};
编译
注意应在微信开发者工具中开启不校验合法域名
执行编译后可得
对于 Todos 列表的增删改查操作需要服务端的支持,此处仅使用 Mock 接口验证数据请求。真实请求逻辑可在服务端篇查看。
数据存储
数据请求小节介绍了如何发起数据请求,但数据请求需要相应的服务端,服务端未完成的情况下,Todo 小程序无法完成 Todo 的增删改查。
本小节使用微信所提供的数据存储服务,将 Todos 持久化,实现 Todo 的增删改查。
新建数据存储 service
const storageKey = { todos: 'todos', userInfo: 'userInfo' }; // 获取存储的 Todos async function getStoredTodos () { let todos; try { // eslint-disable-next-line const res = await wx.getStorage({ key: storageKey.todos }); todos = res.data.todos; } catch (err) { console.error(err); } return todos; } // 存储 Todos async function storeTodos (todos) { // eslint-disable-next-line await wx.setStorage({ key: storageKey.todos, data: { todos } }); } // 获取存储的 userInfo async function getUserInfo () { let userInfo; try { // eslint-disable-next-line const res = await wx.getStorage({ key: storageKey.userInfo }); userInfo = res.data.userInfo; } catch (err) { console.error(err); } return userInfo; } // 存储 userInfo async function setUserInfo (userInfo) { // eslint-disable-next-line await wx.setStorage({ key: storageKey.userInfo, data: { userInfo } }); } export default { todos: { get: getStoredTodos, set: storeTodos }, userInfo: { get: getUserInfo, set: setUserInfo } };
todos 组件和 add-todo 组件添加逻辑
todos 组件:
- 增加获取用户信息函数 getUserInfo
- 增加初始化 Todos 函数:initTodos
- 增加添加 Todo 函数:addTodo
- 增加删除 Todo 函数:delTodo
- 修改编辑 Todo 函数:onTodoChange
add-todo 组件// src/pages/todos/index.jsx import React, { useState } from 'react'; import { usePageShow } from 'ice'; // 引入 storage service import storageService from '@/services/storage'; import AddButton from '@/components/add-button'; import logo from '@/public/logo.svg'; import styles from './index.module.scss'; const Todos = () => { // state const [userInfo, setUserInfo] = useState({}); const [todos, setTodos] = useState([]); // handlers // user const getUserInfo = async () => { // eslint-disable-next-line const storedUserInfo = await storageService.userInfo.get(); // eslint-disable-next-line const res = await wx.getUserInfo(); // 获取用户信息 const userInfo = res.userInfo; setUserInfo(userInfo); await storageService.userInfo.set(userInfo); }; // todos // 添加 Todo const addTodo = async () => { wx.redirectTo({ url: '/pages/add-todo/index' }); }; // 修改 Todo 完成状态 const onTodoChange = async id => { let changedContent = {}; const changedTodos = todos.map(todo => { const { id: curId } = todo; const { completed } = todo.content; if (id === curId) { changedContent = { ...todo.content, completed: id === curId ? !completed : completed }; } return { ...todo, content: { ...todo.content, completed: id === curId ? !completed : completed } }; }); setTodos(changedTodos); await storageService.todos.set(changedTodos); }; // 删除 Todo async function delTodo (id) { const changedTodos = todos.filter(todo => { const { id: curId } = todo; return id !== curId; }); setTodos(changedTodos); await storageService.todos.set(changedTodos); }; // 初始化 Todos const initTodos = async () => { const storedTodos = await storageService.todos.get(); const mergedTodos = storedTodos || []; console.log(storedTodos, mergedTodos); setTodos(mergedTodos); await storageService.todos.set(mergedTodos); }; // lifecyle function usePageShow(async () => { console.log('page show'); // eslint-disable-next-line const storedUserInfo = await storageService.userInfo.get(); setUserInfo(storedUserInfo || {}); await initTodos(); }); return ( <div className={styles['page-todos']}> <div className={styles.user}> <button type='button' open-type="getUserInfo" onClick={getUserInfo} className={styles['login-button']} > <div style={{display: 'flex', flexDirection: 'column'}}> <img className={styles.avatar} src={userInfo.avatarUrl ? userInfo.avatarUrl : logo} alt="用户头像" /> <span className={styles.nickname}>{userInfo.nickName ? `${userInfo.nickName}'s` : 'My' } Todo List</span> </div> </button> </div> <div className={styles['todo-items']}> <div className={styles['todo-items-group']}> { todos.map(todo => ( <div style={{position: 'relative'}} key={todo.id}> <div className={`${styles['todo-item']} ${todo.content.completed ? styles.checked : ''}`} onClick={() => onTodoChange(todo.id)} > <checkbox className={styles['todo-item-checkbox']} checked={todo.content.completed} /> <span className={styles['todo-item-text']}>{todo.content.text}</span> </div> <div className={styles['close-wrapper']} onClick={() => delTodo(todo.id)}> <div className={styles.close}/> </div> </div> )) } </div> </div> <div className={styles['todo-footer']}> <AddButton text="Add Todo" onClickMe={addTodo} /> </div> </div> ); }; export default Todos;
- 修改添加 Todo 函数:add
执行编译,运行项目// src/pages/add-todo/index.jsx import React, { useState } from 'react'; // 引入 storage service import storageService from '@/services/storage'; import AddButton from '@/components/add-button'; import styles from './index.module.scss'; const AddTodo = () => { // state const [value, setValue] = useState(''); // handlers // 输入监听函数 const onChange = (e) => { setValue(e.target.value); }; // 添加 Todo const add = async () => { const curTodos = await storageService.todos.get(); const todo = { id: (new Date()).getTime(), content: { text: value, completed: false } } const newTodos = curTodos.concat(todo); storageService.todos.set(newTodos); // eslint-disable-next-line wx.redirectTo({ url: '/pages/todos/index' }); }; return ( <div className={styles['page-add-todo']}> <div className={styles['add-todo']}> <input className={styles['add-todo-input']} placeholder="What needs to be done?" value={value} onChange={() => {}} onInput={onChange} /> </div> <div className={styles['todo-footer']}> <AddButton text="Add Todo" onClickMe={add}/> </div> </div> ); }; export default AddTodo;
此时,本程序可获取用户基本信息,增加 Todo。
小结
至此,小程序部分已基本完成。
本文介绍了使用 icejs开发小程序的基本流程,包括使用组件 UI 的编写;路由及其它小程序相关的项目配置;发起数据请求;调用微信小程序 API 获取用户信息和存储数据。
小程序同步 Todo 列表至服务端需要服务端的配合,故将该功能在服务端篇进行实现。
小程序代码见 miniprogram-materials/scaffolds/todos,可结合服务端 icejs-miniapp-admin/server 一起运行查看效果。