├── src ├── pages │ ├── home │ │ ├── index.scss │ │ ├── index.config.ts │ │ └── index.tsx │ ├── index │ │ ├── index.scss │ │ ├── index.config.ts │ │ └── index.tsx │ ├── profile │ │ ├── index.scss │ │ ├── index.config.ts │ │ └── index.tsx │ └── blank │ │ ├── index.config.ts │ │ └── index.tsx ├── api │ ├── index.ts │ └── core │ │ ├── config.ts │ │ └── http.ts ├── hooks │ ├── index.ts │ └── useAuth.ts ├── libs │ ├── index.ts │ ├── storage.ts │ └── utils.ts ├── models │ ├── index.ts │ ├── selectors.ts │ ├── counter.ts │ ├── auth.ts │ └── user.ts ├── assets │ └── tabbar │ │ ├── home.png │ │ ├── profile.png │ │ ├── home-selected.png │ │ └── profile-selected.png ├── app.scss ├── app.ts ├── index.html ├── app.config.ts └── router │ └── index.ts ├── .env ├── .env.test ├── .env.production ├── .env.development ├── .prettierrc ├── project.tt.json ├── jest.config.ts ├── eslint.config.mjs ├── .editorconfig ├── project.private.config.json ├── __tests__ └── index.test.js ├── postcss.config.js ├── .gitignore ├── tailwind.config.js ├── mock └── index.ts ├── config ├── dev.ts ├── prod.ts └── index.ts ├── babel.config.js ├── README.md ├── project.config.json ├── tsconfig.json ├── types └── global.d.ts ├── LICENSE ├── .vscode └── settings.json └── package.json /src/pages/home/index.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/index/index.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/profile/index.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | TARO_APP_NAME= 2 | TARO_APP_PORT= 3 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # TARO_APP_ID="测试环境下的小程序appid" -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # TARO_APP_ID="生产环境下的小程序appid" -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core/http' 2 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useAuth' 2 | -------------------------------------------------------------------------------- /src/pages/blank/index.config.ts: -------------------------------------------------------------------------------- 1 | export default definePageConfig({}) 2 | -------------------------------------------------------------------------------- /src/libs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './storage' 2 | export { default as utils } from './utils' 3 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './counter' 2 | export * from './user' 3 | export * from './auth' 4 | -------------------------------------------------------------------------------- /src/pages/home/index.config.ts: -------------------------------------------------------------------------------- 1 | export default definePageConfig({ 2 | navigationBarTitleText: 'Home', 3 | }) 4 | -------------------------------------------------------------------------------- /src/pages/index/index.config.ts: -------------------------------------------------------------------------------- 1 | export default definePageConfig({ 2 | navigationBarTitleText: '首页', 3 | }) 4 | -------------------------------------------------------------------------------- /src/assets/tabbar/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/welives/taro-react-starter/HEAD/src/assets/tabbar/home.png -------------------------------------------------------------------------------- /src/app.scss: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | -------------------------------------------------------------------------------- /src/assets/tabbar/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/welives/taro-react-starter/HEAD/src/assets/tabbar/profile.png -------------------------------------------------------------------------------- /src/pages/profile/index.config.ts: -------------------------------------------------------------------------------- 1 | export default definePageConfig({ 2 | navigationBarTitleText: 'Profile', 3 | }) 4 | -------------------------------------------------------------------------------- /src/assets/tabbar/home-selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/welives/taro-react-starter/HEAD/src/assets/tabbar/home-selected.png -------------------------------------------------------------------------------- /src/assets/tabbar/profile-selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/welives/taro-react-starter/HEAD/src/assets/tabbar/profile-selected.png -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | # 配置文档参考 https://taro-docs.jd.com/docs/next/env-mode-config 2 | # TARO_APP_ID="开发环境下的小程序appid" 3 | TARO_APP_API=http://localhost:9527 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "printWidth": 120, 6 | "singleQuote": true, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /project.tt.json: -------------------------------------------------------------------------------- 1 | { 2 | "miniprogramRoot": "./", 3 | "projectname": "taro-react-starter", 4 | "appid": "testAppId", 5 | "setting": { 6 | "es6": false, 7 | "minified": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | const defineJestConfig = require('@tarojs/test-utils-react/dist/jest.js').default 2 | 3 | module.exports = defineJestConfig({ 4 | testEnvironment: 'jsdom', 5 | testMatch: ['/__tests__/?(*.)+(spec|test).[jt]s?(x)'], 6 | }) 7 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | ignores: ['node_modules', '**/node_modules/**', 'dist', '**/dist/**', '.swc', '**/.swc/**'], 5 | formatters: true, 6 | typescript: true, 7 | react: true, 8 | }) 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /project.private.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html", 3 | "projectname": "taro-react-starter", 4 | "setting": { 5 | "compileHotReLoad": true 6 | }, 7 | "libVersion": "3.2.0" 8 | } 9 | -------------------------------------------------------------------------------- /__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import TestUtils from '@tarojs/test-utils-react' 2 | 3 | describe('testing', () => { 4 | it('test', async () => { 5 | const testUtils = new TestUtils() 6 | await testUtils.createApp() 7 | await testUtils.PageLifecycle.onShow('pages/index/index') 8 | expect(testUtils.html()).toMatchSnapshot() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react' 2 | import { useLaunch } from '@tarojs/taro' 3 | import './app.scss' 4 | 5 | function App({ children }: PropsWithChildren) { 6 | useLaunch(() => { 7 | console.log('App launched.') 8 | }) 9 | 10 | // children 是将要会渲染的页面 11 | return children 12 | } 13 | 14 | export default App 15 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const isH5 = process.env.TARO_ENV === 'h5' 2 | module.exports = { 3 | plugins: { 4 | 'tailwindcss': {}, 5 | 'autoprefixer': {}, 6 | 'postcss-rem-to-responsive-pixel': { 7 | rootValue: 32, // 1rem = 32rpx 8 | propList: ['*'], // 默认所有属性都转化 9 | transformUnit: isH5 ? 'px' : 'rpx', // 转化的单位,可以变成 px / rpx 10 | }, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/blank/index.tsx: -------------------------------------------------------------------------------- 1 | import { useLoad } from '@tarojs/taro' 2 | import { useUserStore } from '@/models' 3 | import router from '@/router' 4 | 5 | export default function Blank() { 6 | const isLogged = useUserStore.use.isLogged() 7 | useLoad(() => { 8 | if (isLogged) 9 | router.switchTab({ url: '/pages/home/index' }) 10 | else 11 | router.reLaunch({ url: '/pages/index/index' }) 12 | }) 13 | return null 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | deploy_versions/ 3 | .temp/ 4 | .rn_temp/ 5 | node_modules/ 6 | .swc 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | pnpm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | lerna-debug.log* 16 | 17 | # OS 18 | .DS_Store 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | *.code-workspace 29 | 30 | # local env files 31 | .env*.local 32 | *.rest 33 | *.http 34 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require('tailwindcss/colors') 2 | 3 | delete colors.lightBlue 4 | delete colors.warmGray 5 | delete colors.trueGray 6 | delete colors.coolGray 7 | delete colors.blueGray 8 | /** @type {import('tailwindcss').Config} */ 9 | module.exports = { 10 | content: ['./public/index.html', './src/**/*.{html,js,ts,jsx,tsx}'], 11 | theme: { 12 | extend: { colors }, 13 | }, 14 | corePlugins: { 15 | preflight: false, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /mock/index.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'POST /api/login': { 3 | code: '200', 4 | message: 'ok', 5 | data: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MjMyODU2LCJzZXNzaW9uIjoiOTRlZTZjOThmMmY4NzgzMWUzNzRmZTBiMzJkYTIwMGMifQ.z5Llnhe4muNsanXQSV-p1DJ-89SADVE-zIkHpM0uoQs', 6 | success: true, 7 | }, 8 | 'GET /api/user': { 9 | code: '200', 10 | message: 'ok', 11 | data: { 12 | id: 1, 13 | name: 'jandan', 14 | }, 15 | success: true, 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /config/dev.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfigExport } from '@tarojs/cli' 2 | 3 | export default { 4 | logger: { 5 | quiet: false, 6 | stats: true, 7 | }, 8 | mini: {}, 9 | plugins: ['@tarojs/plugin-mock'], 10 | h5: { 11 | devServer: { 12 | proxy: { 13 | '/api': { 14 | target: process.env.TARO_APP_API, 15 | changeOrigin: true, 16 | pathRewrite: { '^/api': '' }, 17 | }, 18 | }, 19 | }, 20 | }, 21 | } satisfies UserConfigExport 22 | -------------------------------------------------------------------------------- /src/models/selectors.ts: -------------------------------------------------------------------------------- 1 | import type { StoreApi, UseBoundStore } from 'zustand' 2 | 3 | type WithSelectors = S extends { getState: () => infer T } ? S & { use: { [K in keyof T]: () => T[K] } } : never 4 | 5 | function createSelectors>>(_store: S) { 6 | const store = _store as WithSelectors 7 | store.use = {} 8 | for (const k of Object.keys(store.getState())) { 9 | ;(store.use as any)[k] = () => store(s => s[k as keyof typeof s]) 10 | } 11 | 12 | return store 13 | } 14 | 15 | export default createSelectors 16 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // babel-preset-taro 更多选项和默认值: 2 | // https://github.com/NervJS/taro/blob/next/packages/babel-preset-taro/README.md 3 | module.exports = { 4 | presets: [ 5 | [ 6 | 'taro', 7 | { 8 | framework: 'react', 9 | ts: true, 10 | }, 11 | ], 12 | ], 13 | plugins: [ 14 | [ 15 | 'import', 16 | { 17 | libraryName: '@nutui/nutui-react-taro', 18 | libraryDirectory: 'dist/esm', 19 | style: 'css', 20 | camel2DashComponentName: false, 21 | }, 22 | 'nutui-react-taro', 23 | ], 24 | ], 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # taro-react-starter 2 | 3 | 一个开箱即用的基于`Taro3` + `React` + `Zustand` + `TailwindCSS` + `TypeScript`的项目模板 4 | 5 | 这个工程的搭建笔记可以[在这里查看](https://welives.github.io/blog/front-end/engineering/taro/create-react.html) 6 | 7 | ## 使用 8 | 9 | ```sh 10 | pnpm install 11 | ``` 12 | 13 | ### 运行 14 | 15 | ```sh 16 | pnpm dev:h5 17 | pnpm dev:weapp 18 | ``` 19 | 20 | ## 相关文档 21 | 22 | - [Taro](https://nervjs.github.io/taro-docs/docs/) 23 | - [Zustand](https://zustand-demo.pmnd.rs/) 24 | - [NutUI-React](https://nutui.jd.com/) 25 | - [TypeScript](https://www.tslang.cn/) 26 | - [TailwindCSS](https://tailwind.nodejs.cn/) 27 | - [ESLint](https://eslint.nodejs.cn/) 28 | - [Prettier](https://prettier.nodejs.cn/) 29 | -------------------------------------------------------------------------------- /src/models/counter.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { immer } from 'zustand/middleware/immer' 3 | import createSelectors from './selectors' 4 | 5 | interface State { 6 | count: number 7 | } 8 | interface Action { 9 | inc: () => void 10 | dec: () => void 11 | } 12 | const initialState: State = { 13 | count: 0, 14 | } 15 | 16 | const counterStore = create()( 17 | immer((set, get) => ({ 18 | count: 0, 19 | inc: () => set(state => ({ count: state.count + 1 })), 20 | dec: () => set(state => ({ count: state.count - 1 })), 21 | })), 22 | ) 23 | export const useCounterStore = createSelectors(counterStore) 24 | export function useCounterReset() { 25 | counterStore.setState(initialState) 26 | } 27 | -------------------------------------------------------------------------------- /src/pages/profile/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Text, View } from '@tarojs/components' 2 | import { useCounterStore } from '@/models' 3 | import { useAuth } from '@/hooks' 4 | import './index.scss' 5 | 6 | export default function Profile() { 7 | useAuth() 8 | const { count, inc, dec } = useCounterStore() 9 | 10 | return ( 11 | 12 | Profile Page 13 | 14 | 17 | {count} 18 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /project.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "miniprogramRoot": "dist/", 3 | "projectname": "taro-react-starter", 4 | "description": "taro-react-starter", 5 | "appid": "wx3ac3f653002c2ab6", 6 | "setting": { 7 | "urlCheck": true, 8 | "es6": false, 9 | "enhance": false, 10 | "compileHotReLoad": false, 11 | "postcss": false, 12 | "minified": false, 13 | "babelSetting": { 14 | "ignore": [], 15 | "disablePlugins": [], 16 | "outputPath": "" 17 | } 18 | }, 19 | "compileType": "miniprogram", 20 | "libVersion": "3.2.1", 21 | "srcMiniprogramRoot": "dist/", 22 | "packOptions": { 23 | "ignore": [], 24 | "include": [] 25 | }, 26 | "condition": {}, 27 | "editorSetting": { 28 | "tabIndent": "insertSpaces", 29 | "tabSize": 2 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | taro-react-starter 15 | 18 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "jsx": "react-jsx", 5 | "experimentalDecorators": true, 6 | "baseUrl": ".", 7 | "rootDir": ".", 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "paths": { 11 | "@/*": ["src/*"] 12 | }, 13 | "resolveJsonModule": true, 14 | "typeRoots": ["node_modules/@types"], 15 | "strictNullChecks": false, 16 | "preserveConstEnums": true, 17 | "removeComments": false, 18 | "noImplicitAny": false, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "outDir": "lib", 22 | "allowSyntheticDefaultImports": true, 23 | "sourceMap": true, 24 | "allowJs": true 25 | }, 26 | "include": ["./src", "./types", "./config"], 27 | "compileOnSave": false 28 | } 29 | -------------------------------------------------------------------------------- /types/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.png' 4 | declare module '*.gif' 5 | declare module '*.jpg' 6 | declare module '*.jpeg' 7 | declare module '*.svg' 8 | declare module '*.css' 9 | declare module '*.less' 10 | declare module '*.scss' 11 | declare module '*.sass' 12 | declare module '*.styl' 13 | 14 | declare namespace NodeJS { 15 | interface ProcessEnv { 16 | /** NODE 内置环境变量, 会影响到最终构建生成产物 */ 17 | NODE_ENV: 'development' | 'production' 18 | /** 当前构建的平台 */ 19 | TARO_ENV: 'weapp' | 'swan' | 'alipay' | 'h5' | 'rn' | 'tt' | 'quickapp' | 'qq' | 'jd' 20 | /** 21 | * 当前构建的小程序 appid 22 | * @description 若不同环境有不同的小程序,可通过在 env 文件中配置环境变量`TARO_APP_ID`来方便快速切换 appid, 而不必手动去修改 dist/project.config.json 文件 23 | * @see https://taro-docs.jd.com/docs/next/env-mode-config#特殊环境变量-taro_app_id 24 | */ 25 | TARO_APP_ID: string 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/pages/home/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Text, View } from '@tarojs/components' 2 | import { useCounterReset, useCounterStore } from '@/models' 3 | import { useAuth } from '@/hooks' 4 | import './index.scss' 5 | 6 | export default function Home() { 7 | useAuth() 8 | const count = useCounterStore.use.count() 9 | const inc = useCounterStore.use.inc() 10 | const dec = useCounterStore.use.dec() 11 | 12 | return ( 13 | 14 | Home Page 15 | 16 | 19 | {count} 20 | 23 | 24 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/app.config.ts: -------------------------------------------------------------------------------- 1 | const pages = ['pages/index/index', 'pages/blank/index', 'pages/home/index', 'pages/profile/index'] 2 | export default defineAppConfig({ 3 | animation: true, 4 | entryPagePath: 'pages/blank/index', 5 | pages, 6 | tabBar: { 7 | color: '#666666', 8 | selectedColor: '#4965f2', 9 | backgroundColor: '#fefefe', 10 | list: [ 11 | { 12 | pagePath: 'pages/home/index', 13 | iconPath: 'assets/tabbar/home.png', 14 | selectedIconPath: 'assets/tabbar/home-selected.png', 15 | text: '首页', 16 | }, 17 | { 18 | pagePath: 'pages/profile/index', 19 | iconPath: 'assets/tabbar/profile.png', 20 | selectedIconPath: 'assets/tabbar/profile-selected.png', 21 | text: '我的', 22 | }, 23 | ], 24 | }, 25 | window: { 26 | backgroundTextStyle: 'light', 27 | navigationBarBackgroundColor: '#fff', 28 | navigationBarTitleText: 'WeChat', 29 | navigationBarTextStyle: 'black', 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /src/models/auth.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { immer } from 'zustand/middleware/immer' 3 | import { createJSONStorage, persist } from 'zustand/middleware' 4 | import { StorageSceneKey, zustandStorage } from '../libs' 5 | import createSelectors from './selectors' 6 | 7 | interface Redirect { 8 | url: string 9 | tab?: boolean 10 | } 11 | 12 | interface State { 13 | redirect: Redirect | null 14 | } 15 | interface Action { 16 | setRedirect: (value: Redirect) => void 17 | } 18 | 19 | const store = create()( 20 | immer( 21 | persist( 22 | (set, get) => ({ 23 | redirect: null, 24 | setRedirect: value => set({ redirect: value }), 25 | }), 26 | { 27 | name: StorageSceneKey.AUTH, 28 | storage: createJSONStorage(() => zustandStorage), 29 | }, 30 | ), 31 | ), 32 | ) 33 | 34 | export const useAuthStore = createSelectors(store) 35 | export function useAuthReset() { 36 | store.setState({ redirect: null }) 37 | } 38 | -------------------------------------------------------------------------------- /src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentInstance, useDidShow } from '@tarojs/taro' 2 | import { useAuthStore, useUserStore } from '../models' 3 | import router from '../router' 4 | 5 | const tabbar = ['/pages/home/index', '/pages/profile/index'] 6 | 7 | export function useAuth() { 8 | const isLogged = useUserStore.use.isLogged() 9 | const setRedirect = useAuthStore.use.setRedirect() 10 | const current = getCurrentInstance().router 11 | const path = current ? current.path.split('?')[0] : '' 12 | const isTab = tabbar.includes(path) 13 | const routeParams = current?.params 14 | const params = {} 15 | for (const [key, value] of Object.entries(routeParams ?? {})) { 16 | if (!['stamp', '$taroTimestamp'].includes(key)) 17 | params[key] = value 18 | } 19 | useDidShow(() => { 20 | if (!isLogged) { 21 | const str = new URLSearchParams(params).toString() 22 | setRedirect({ tab: isTab, url: str ? `${path}?${str}` : path }) 23 | router.reLaunch({ url: '/pages/index/index' }) 24 | } 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /src/libs/storage.ts: -------------------------------------------------------------------------------- 1 | import { getStorageSync, removeStorageSync, setStorageSync } from '@tarojs/taro' 2 | import type { StateStorage } from 'zustand/middleware' 3 | 4 | enum StorageSceneKey { 5 | USER = 'storage-user', 6 | AUTH = 'storage-auth', 7 | } 8 | 9 | function getItem(key: string): T { 10 | const value = getStorageSync(key) 11 | return value ? JSON.parse(value) ?? null : null 12 | } 13 | function setItem(key: string, value: any) { 14 | setStorageSync(key, JSON.stringify(value)) 15 | } 16 | function removeItem(key: string) { 17 | removeStorageSync(key) 18 | } 19 | 20 | export { getItem, setItem, removeItem, StorageSceneKey } 21 | 22 | /** @description 用来给 zustand 持久化存储的方法 */ 23 | export const zustandStorage: StateStorage = { 24 | getItem: (key: string) => { 25 | const value = getStorageSync(key) 26 | return value ?? null 27 | }, 28 | setItem: (key: string, value) => { 29 | setStorageSync(key, value) 30 | }, 31 | removeItem: (key: string) => { 32 | removeStorageSync(key) 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 welives 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/models/user.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { immer } from 'zustand/middleware/immer' 3 | import { createJSONStorage, persist } from 'zustand/middleware' 4 | import { StorageSceneKey, zustandStorage } from '../libs' 5 | import createSelectors from './selectors' 6 | 7 | interface State { 8 | token: string 9 | isLogged: boolean 10 | } 11 | interface Action { 12 | setToken: (token: string) => void 13 | removeToken: () => void 14 | } 15 | 16 | const initialState: State = { 17 | token: '', 18 | isLogged: false, 19 | } 20 | const store = create()( 21 | immer( 22 | persist( 23 | (set, get) => ({ 24 | token: '', 25 | isLogged: false, 26 | setToken: token => set({ token, isLogged: true }), 27 | removeToken: () => set({ token: '', isLogged: false }), 28 | }), 29 | { 30 | // ! 注意这里的name是当前这个Zustand模块进行缓存时的唯一key, 每个需要缓存的Zustand模块都必须分配一个唯一key 31 | name: StorageSceneKey.USER, 32 | storage: createJSONStorage(() => zustandStorage), 33 | }, 34 | ), 35 | ), 36 | ) 37 | 38 | export const useUserStore = createSelectors(store) 39 | export function useUserReset() { 40 | store.setState(initialState) 41 | } 42 | -------------------------------------------------------------------------------- /src/pages/index/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, View } from '@tarojs/components' 2 | import { request } from '@/api' 3 | import { useAuthReset, useAuthStore, useUserStore } from '@/models' 4 | import router from '@/router' 5 | import './index.scss' 6 | 7 | export default function Index() { 8 | const setToken = useUserStore.use.setToken() 9 | const auth = useAuthStore() 10 | const login = async () => { 11 | const res = await request('/api/login', { 12 | method: 'POST', 13 | }) 14 | setToken(res.data) 15 | if (auth.redirect?.url) { 16 | const success = () => { 17 | useAuthReset() 18 | } 19 | auth.redirect.tab 20 | ? router.switchTab({ 21 | url: auth.redirect.url, 22 | success, 23 | }) 24 | : router.redirectTo({ 25 | url: auth.redirect.url, 26 | success, 27 | }) 28 | } 29 | else { 30 | router.switchTab({ url: '/pages/home/index' }) 31 | } 32 | } 33 | 34 | return ( 35 | 36 | 39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /config/prod.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfigExport } from '@tarojs/cli' 2 | 3 | export default { 4 | mini: {}, 5 | h5: { 6 | /** 7 | * WebpackChain 插件配置 8 | * @docs https://github.com/neutrinojs/webpack-chain 9 | */ 10 | // webpackChain (chain) { 11 | // /** 12 | // * 如果 h5 端编译后体积过大,可以使用 webpack-bundle-analyzer 插件对打包体积进行分析。 13 | // * @docs https://github.com/webpack-contrib/webpack-bundle-analyzer 14 | // */ 15 | // chain.plugin('analyzer') 16 | // .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, []) 17 | // /** 18 | // * 如果 h5 端首屏加载时间过长,可以使用 prerender-spa-plugin 插件预加载首页。 19 | // * @docs https://github.com/chrisvfritz/prerender-spa-plugin 20 | // */ 21 | // const path = require('path') 22 | // const Prerender = require('prerender-spa-plugin') 23 | // const staticDir = path.join(__dirname, '..', 'dist') 24 | // chain 25 | // .plugin('prerender') 26 | // .use(new Prerender({ 27 | // staticDir, 28 | // routes: [ '/pages/index/index' ], 29 | // postProcess: (context) => ({ ...context, outputPath: path.join(staticDir, 'index.html') }) 30 | // })) 31 | // } 32 | }, 33 | } satisfies UserConfigExport 34 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | // (remove this if your ESLint extension above v3.0.5) 4 | "eslint.experimental.useFlatConfig": true, 5 | 6 | // Disable the default formatter, use eslint instead 7 | "prettier.enable": true, 8 | "editor.formatOnSave": false, 9 | 10 | // Auto fix 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll.eslint": "explicit", 13 | "source.organizeImports": "never" 14 | }, 15 | 16 | // Silent the stylistic rules in you IDE, but still auto fix them 17 | "eslint.rules.customizations": [ 18 | { "rule": "style/*", "severity": "off" }, 19 | { "rule": "format/*", "severity": "off" }, 20 | { "rule": "*-indent", "severity": "off" }, 21 | { "rule": "*-spacing", "severity": "off" }, 22 | { "rule": "*-spaces", "severity": "off" }, 23 | { "rule": "*-order", "severity": "off" }, 24 | { "rule": "*-dangle", "severity": "off" }, 25 | { "rule": "*-newline", "severity": "off" }, 26 | { "rule": "*quotes", "severity": "off" }, 27 | { "rule": "*semi", "severity": "off" } 28 | ], 29 | 30 | // Enable eslint for all supported languages 31 | "eslint.validate": [ 32 | "javascript", 33 | "javascriptreact", 34 | "typescript", 35 | "typescriptreact", 36 | "vue", 37 | "html", 38 | "markdown", 39 | "json", 40 | "jsonc", 41 | "yaml", 42 | "toml", 43 | "xml", 44 | "gql", 45 | "graphql", 46 | "astro" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /src/api/core/config.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError, AxiosResponse } from 'axios' 2 | import type { RequestConfig } from './http' 3 | 4 | // 错误处理方案:错误类型 5 | enum ErrorShowType { 6 | SILENT = 0, 7 | WARN_MESSAGE = 1, 8 | ERROR_MESSAGE = 2, 9 | NOTIFICATION = 3, 10 | REDIRECT = 9, 11 | } 12 | 13 | // 与后端约定的响应数据格式 14 | interface ResponseStructure { 15 | success: boolean 16 | code: string 17 | data?: T 18 | message?: string 19 | [key: string]: any 20 | } 21 | /** 22 | * 业务错误处理 23 | */ 24 | function bizErrorHandler(error: any) { 25 | if (error.info) { 26 | const { errorMessage, errorCode, showType } = error.info 27 | switch (showType) { 28 | case ErrorShowType.SILENT: 29 | // do nothing 30 | break 31 | case ErrorShowType.WARN_MESSAGE: 32 | // TODO 33 | break 34 | case ErrorShowType.ERROR_MESSAGE: 35 | // TODO 36 | break 37 | case ErrorShowType.NOTIFICATION: 38 | // TODO 39 | break 40 | case ErrorShowType.REDIRECT: 41 | // TODO 42 | break 43 | default: 44 | // TODO 45 | console.error(errorMessage) 46 | } 47 | } 48 | } 49 | /** 50 | * 请求错误处理 51 | */ 52 | function responseStatusHandler(error: AxiosError) { 53 | if (error.response) { 54 | const { status } = error.response as AxiosResponse 55 | switch (status) { 56 | case 401: 57 | // TODO 58 | break 59 | case 403: 60 | // TODO 61 | break 62 | case 404: 63 | // TODO 64 | break 65 | default: 66 | console.error(`Response status:${status}`) 67 | } 68 | } 69 | else { 70 | console.error(error.message) 71 | } 72 | } 73 | 74 | const requestConfig: RequestConfig = { 75 | errorConfig: { 76 | // 抛出错误 77 | errorThrower: (res) => { 78 | const { success, data, code, message, errorCode, errorMessage, showType } = res 79 | if (!success) { 80 | const error: any = new Error(errorMessage || message) 81 | error.name = 'BizError' 82 | error.info = { 83 | errorCode: errorCode ?? code, 84 | errorMessage: errorMessage ?? message, 85 | showType, 86 | data, 87 | } 88 | throw error // 抛出自定义的错误,请求方法中的 .catch 部分会捕获 89 | } 90 | }, 91 | // 错误接收及处理 92 | errorHandler: (error: any, opts) => { 93 | if (opts?.skipErrorHandler) 94 | return 95 | // 自定义错误的处理 96 | if (error.name === 'BizError') { 97 | bizErrorHandler(error) 98 | } 99 | else if (error.name === 'AxiosError') { 100 | // Axios 的错误 101 | // 请求成功发出且服务器也响应了状态码,但状态代码超出了 2xx 的范围 102 | responseStatusHandler(error) 103 | } 104 | else if (error.request) { 105 | // 请求已经成功发起,但没有收到响应 106 | // error.request 在浏览器中是 XMLHttpRequest 的实例 107 | // 而在node.js中是 http.ClientRequest 的实例 108 | // TODO 109 | console.error('None response! Please retry.') 110 | } 111 | else { 112 | // 发送请求时出了点问题 113 | // TODO 114 | console.error('Request error, please retry') 115 | } 116 | }, 117 | }, 118 | // 请求拦截器 119 | requestInterceptors: [ 120 | [ 121 | (config) => { 122 | // 拦截请求配置,进行个性化处理。 123 | // TODO 124 | return { ...config } 125 | }, 126 | (error: AxiosError) => { 127 | return Promise.reject(error) 128 | }, 129 | ], 130 | ], 131 | // 响应拦截器,这里只处理状态码 2xx 的情况 132 | responseInterceptors: [ 133 | (response) => { 134 | // 拦截响应数据,进行个性化处理 135 | const { config, data } = response 136 | !data 137 | && requestConfig.errorConfig?.errorThrower?.({ 138 | success: false, 139 | code: 'E0001', 140 | message: '缺少响应数据', 141 | }) 142 | if (!data.success) { 143 | // TODO 144 | requestConfig.errorConfig?.errorThrower?.(data) 145 | } 146 | return response 147 | }, 148 | ], 149 | } 150 | 151 | export default requestConfig 152 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "taro-react-starter", 3 | "version": "1.0.0", 4 | "description": "开箱即用的基于Taro3 + React + Zustand + TailwindCSS + TypeScript的模板", 5 | "author": "welives", 6 | "license": "MIT", 7 | "keywords": [ 8 | "Taro3", 9 | "React", 10 | "Zustand", 11 | "TailwindCSS", 12 | "TypeScript", 13 | "ESLint", 14 | "Prettier" 15 | ], 16 | "templateInfo": { 17 | "name": "default", 18 | "typescript": true, 19 | "css": "sass" 20 | }, 21 | "scripts": { 22 | "build:weapp": "taro build --type weapp", 23 | "build:swan": "taro build --type swan", 24 | "build:alipay": "taro build --type alipay", 25 | "build:tt": "taro build --type tt", 26 | "build:h5": "taro build --type h5", 27 | "build:rn": "taro build --type rn", 28 | "build:qq": "taro build --type qq", 29 | "build:jd": "taro build --type jd", 30 | "build:quickapp": "taro build --type quickapp", 31 | "dev:weapp": "npm run build:weapp -- --watch", 32 | "dev:swan": "npm run build:swan -- --watch", 33 | "dev:alipay": "npm run build:alipay -- --watch", 34 | "dev:tt": "npm run build:tt -- --watch", 35 | "dev:h5": "npm run build:h5 -- --watch", 36 | "dev:rn": "npm run build:rn -- --watch", 37 | "dev:qq": "npm run build:qq -- --watch", 38 | "dev:jd": "npm run build:jd -- --watch", 39 | "dev:quickapp": "npm run build:quickapp -- --watch", 40 | "test": "jest", 41 | "postinstall": "weapp-tw patch", 42 | "lint": "eslint .", 43 | "lint:fix": "eslint . --fix" 44 | }, 45 | "browserslist": [ 46 | "last 3 versions", 47 | "Android >= 4.1", 48 | "ios >= 8" 49 | ], 50 | "dependencies": { 51 | "@babel/runtime": "^7.21.5", 52 | "@nutui/icons-react-taro": "^0.0.1", 53 | "@nutui/nutui-react-taro": "^2.0.23", 54 | "@tarojs/components": "3.6.18", 55 | "@tarojs/helper": "3.6.18", 56 | "@tarojs/plugin-framework-react": "3.6.18", 57 | "@tarojs/plugin-html": "3.6.18", 58 | "@tarojs/plugin-http": "3.6.18", 59 | "@tarojs/plugin-platform-alipay": "3.6.18", 60 | "@tarojs/plugin-platform-h5": "3.6.18", 61 | "@tarojs/plugin-platform-jd": "3.6.18", 62 | "@tarojs/plugin-platform-qq": "3.6.18", 63 | "@tarojs/plugin-platform-swan": "3.6.18", 64 | "@tarojs/plugin-platform-tt": "3.6.18", 65 | "@tarojs/plugin-platform-weapp": "3.6.18", 66 | "@tarojs/react": "3.6.18", 67 | "@tarojs/runtime": "3.6.18", 68 | "@tarojs/shared": "3.6.18", 69 | "@tarojs/taro": "3.6.18", 70 | "axios": "^1.6.0", 71 | "immer": "^10.0.3", 72 | "react": "^18.0.0", 73 | "react-dom": "^18.0.0", 74 | "zustand": "^4.4.6" 75 | }, 76 | "devDependencies": { 77 | "@antfu/eslint-config": "^2.18.0", 78 | "@babel/core": "^7.8.0", 79 | "@eslint-react/eslint-plugin": "^1.5.11", 80 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.5", 81 | "@tarojs/cli": "3.6.18", 82 | "@tarojs/plugin-mock": "^0.0.9", 83 | "@tarojs/taro-loader": "3.6.18", 84 | "@tarojs/test-utils-react": "^0.1.1", 85 | "@tarojs/webpack5-runner": "3.6.18", 86 | "@types/jest": "^29.3.1", 87 | "@types/mockjs": "^1.0.10", 88 | "@types/node": "^18.15.11", 89 | "@types/react": "^18.0.0", 90 | "@types/webpack-env": "^1.13.6", 91 | "@typescript-eslint/eslint-plugin": "^6.2.0", 92 | "@typescript-eslint/parser": "^6.2.0", 93 | "babel-plugin-import": "^1.13.8", 94 | "babel-preset-taro": "3.6.18", 95 | "eslint": "^8.12.0", 96 | "eslint-config-taro": "3.6.18", 97 | "eslint-plugin-format": "^0.1.1", 98 | "eslint-plugin-import": "^2.12.0", 99 | "eslint-plugin-react": "^7.8.2", 100 | "eslint-plugin-react-hooks": "^4.6.2", 101 | "eslint-plugin-react-refresh": "^0.4.7", 102 | "jest": "^29.3.1", 103 | "jest-environment-jsdom": "^29.5.0", 104 | "mockjs": "^1.1.0", 105 | "postcss": "^8.4.18", 106 | "postcss-rem-to-responsive-pixel": "^5.1.3", 107 | "prettier": "^3.0.3", 108 | "react-refresh": "^0.11.0", 109 | "stylelint": "^14.4.0", 110 | "tailwindcss": "^3.3.5", 111 | "ts-node": "^10.9.1", 112 | "tsconfig-paths-webpack-plugin": "^4.0.1", 113 | "typescript": "^5.4.5", 114 | "weapp-tailwindcss": "^2.10.1", 115 | "webpack": "5.78.0" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /config/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { type UserConfigExport, defineConfig } from '@tarojs/cli' 3 | import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin' 4 | import { UnifiedWebpackPluginV5 } from 'weapp-tailwindcss/webpack' 5 | import devConfig from './dev' 6 | import prodConfig from './prod' 7 | 8 | const WeappTailwindcssDisabled = ['h5', 'rn'].includes(process.env.TARO_ENV) 9 | 10 | // https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数 11 | // @ts-expect-error 12 | export default defineConfig(async (merge, { command, mode }) => { 13 | const baseConfig: UserConfigExport = { 14 | projectName: 'taro-react-starter', 15 | date: '2023-11-5', 16 | designWidth: (input: any) => { 17 | // 配置 NutUI 375 尺寸 18 | if (input?.file?.replace(/\\+/g, '/').indexOf('@nutui') > -1) 19 | return 375 20 | 21 | // 全局使用 Taro 默认的 750 尺寸 22 | return 750 23 | }, 24 | deviceRatio: { 25 | 640: 2.34 / 2, 26 | 750: 1, 27 | 375: 2, 28 | 828: 1.81 / 2, 29 | }, 30 | sourceRoot: 'src', 31 | outputRoot: 'dist', 32 | plugins: ['@tarojs/plugin-html', '@tarojs/plugin-http'], 33 | alias: { 34 | '@': path.resolve(__dirname, '../src'), 35 | }, 36 | sass: { 37 | data: '@import "@nutui/nutui-react-taro/dist/styles/variables.scss";', 38 | }, 39 | defineConstants: {}, 40 | copy: { 41 | patterns: [], 42 | options: {}, 43 | }, 44 | framework: 'react', 45 | compiler: { 46 | type: 'webpack5', 47 | prebundle: { 48 | enable: false, 49 | }, 50 | }, 51 | cache: { 52 | enable: false, // Webpack 持久化缓存配置,建议开启。默认配置请参考:https://docs.taro.zone/docs/config-detail#cache 53 | }, 54 | mini: { 55 | postcss: { 56 | pxtransform: { 57 | enable: true, 58 | config: { 59 | selectorBlackList: ['nut-'], 60 | }, 61 | }, 62 | url: { 63 | enable: true, 64 | config: { 65 | limit: 1024, // 设定转换尺寸上限 66 | }, 67 | }, 68 | cssModules: { 69 | enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true 70 | config: { 71 | namingPattern: 'module', // 转换模式,取值为 global/module 72 | generateScopedName: '[name]__[local]___[hash:base64:5]', 73 | }, 74 | }, 75 | }, 76 | webpackChain(chain) { 77 | chain.resolve.plugin('tsconfig-paths').use(TsconfigPathsPlugin) 78 | chain.merge({ 79 | plugin: { 80 | install: { 81 | plugin: UnifiedWebpackPluginV5, 82 | args: [{ appType: 'taro', disabled: WeappTailwindcssDisabled }], 83 | }, 84 | }, 85 | }) 86 | }, 87 | }, 88 | h5: { 89 | publicPath: '/', 90 | staticDirectory: 'static', 91 | esnextModules: ['nutui-react-taro', 'icons-react-taro'], 92 | output: { 93 | filename: 'js/[name].[hash:8].js', 94 | chunkFilename: 'js/[name].[chunkhash:8].js', 95 | }, 96 | miniCssExtractPluginOption: { 97 | ignoreOrder: true, 98 | filename: 'css/[name].[hash].css', 99 | chunkFilename: 'css/[name].[chunkhash].css', 100 | }, 101 | postcss: { 102 | pxtransform: { 103 | enable: true, 104 | config: { 105 | selectorBlackList: ['nut-'], 106 | }, 107 | }, 108 | autoprefixer: { 109 | enable: true, 110 | config: {}, 111 | }, 112 | cssModules: { 113 | enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true 114 | config: { 115 | namingPattern: 'module', // 转换模式,取值为 global/module 116 | generateScopedName: '[name]__[local]___[hash:base64:5]', 117 | }, 118 | }, 119 | }, 120 | webpackChain(chain) { 121 | chain.resolve.plugin('tsconfig-paths').use(TsconfigPathsPlugin) 122 | }, 123 | }, 124 | rn: { 125 | appName: 'taroDemo', 126 | postcss: { 127 | cssModules: { 128 | enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true 129 | }, 130 | }, 131 | }, 132 | } 133 | if (process.env.NODE_ENV === 'development') { 134 | // 本地开发构建配置(不混淆压缩) 135 | return merge({}, baseConfig, devConfig) 136 | } 137 | // 生产构建配置(默认开启压缩混淆等) 138 | return merge({}, baseConfig, prodConfig) 139 | }) 140 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import Taro from '@tarojs/taro' 2 | import { useUserStore } from '../models' 3 | import { utils } from '../libs' 4 | 5 | interface AnyObj { 6 | [key: string]: any 7 | } 8 | type RouterType = 'navigateTo' | 'redirectTo' | 'switchTab' | 'reLaunch' | 'navigateBack' 9 | type SuccessCallback = TaroGeneral.CallbackResult | (TaroGeneral.CallbackResult & { eventChannel: Taro.EventChannel }) 10 | interface TaroRouterOptions 11 | extends Omit, 12 | Omit, 13 | Omit, 14 | Omit, 15 | Omit { 16 | data?: string | AnyObj 17 | success?: (res: S) => void 18 | } 19 | 20 | function searchParams2Obj(params: any) { 21 | const searchParams = new URLSearchParams(params) 22 | const obj: AnyObj = {} 23 | for (const [key, value] of searchParams.entries()) 24 | obj[key] = value 25 | 26 | return obj 27 | } 28 | 29 | /** 30 | * 路由跳转处理 31 | */ 32 | function authCheck(urlKey: string, type: RouterType, options: TaroRouterOptions) { 33 | const isLogged = useUserStore.getState().isLogged 34 | if (authRoutes.includes(urlKey)) { 35 | if (!isLogged) { 36 | // TODO 补充自己的业务逻辑 37 | return 38 | } 39 | navigate(type, options) 40 | } 41 | else { 42 | navigate(type, options) 43 | } 44 | } 45 | /** 46 | * 执行路由跳转 47 | */ 48 | function navigate(type: RouterType, options: TaroRouterOptions) { 49 | const { data, ...rest } = options 50 | if (!['navigateTo', 'redirectTo', 'switchTab', 'reLaunch'].includes(type)) 51 | return 52 | if (!rest.url.startsWith('/')) 53 | rest.url = `/${rest.url}` 54 | 55 | Taro[type](rest) 56 | } 57 | 58 | const singletonEnforcer = Symbol('Router') 59 | class Router { 60 | private static _instance: Router 61 | constructor(enforcer: any) { 62 | if (enforcer !== singletonEnforcer) 63 | throw new Error('Cannot initialize single instance') 64 | } 65 | 66 | static get instance() { 67 | // 如果已经存在实例则直接返回, 否则实例化后返回 68 | this._instance || (this._instance = new Router(singletonEnforcer)) 69 | return this._instance 70 | } 71 | 72 | /** 73 | * 路由中间件,做跳转前的代理 74 | */ 75 | private middleware(type: RouterType, options: TaroRouterOptions) { 76 | let { url = '', data = {}, events, ...rest } = options 77 | let [urlKey, queryStr] = url.split('?') 78 | // 单独存一份url,待会要用 79 | urlKey = urlKey 80 | .split('/') 81 | .filter(e => e !== '') 82 | .join('/') 83 | try { 84 | if (type === 'navigateBack') { 85 | Taro.navigateBack(rest) 86 | } 87 | else { 88 | if (!urlKey.trim() || !routes.includes(urlKey)) 89 | throw new Error('无效的路由') 90 | 91 | if (type === 'switchTab') { 92 | url = urlKey 93 | } 94 | else { 95 | let obj: AnyObj = {} 96 | if (data && typeof data === 'string' && data.trim()) 97 | data = searchParams2Obj(data) 98 | 99 | if (queryStr && queryStr.trim()) 100 | obj = searchParams2Obj(queryStr) 101 | 102 | const str = new URLSearchParams(utils.merge(data as object, obj)).toString() 103 | url = str ? `${urlKey}?${str}` : urlKey 104 | } 105 | authCheck(urlKey, type, { ...rest, url, events }) 106 | } 107 | } 108 | catch (error) { 109 | // TODO 110 | console.error(error.message) 111 | } 112 | } 113 | 114 | /** 115 | * 跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面 116 | */ 117 | switchTab(options: TaroRouterOptions) { 118 | this.middleware('switchTab', options) 119 | } 120 | 121 | /** 122 | * 关闭所有页面,打开到应用内的某个页面 123 | */ 124 | reLaunch(options: TaroRouterOptions) { 125 | this.middleware('reLaunch', options) 126 | } 127 | 128 | /** 129 | * 关闭当前页面,跳转到应用内的某个页面。但是不允许跳转到 tabbar 页面 130 | */ 131 | redirectTo(options: TaroRouterOptions) { 132 | this.middleware('redirectTo', options) 133 | } 134 | 135 | /** 136 | * 保留当前页面,跳转到应用内的某个页面。但是不能跳到 tabbar 页面 137 | */ 138 | navigateTo(options: TaroRouterOptions) { 139 | this.middleware('navigateTo', options) 140 | } 141 | 142 | /** 143 | * 关闭当前页面,返回上一页面或多级页面 144 | */ 145 | navigateBack(options: Omit) { 146 | this.middleware('navigateBack', { url: '', ...options }) 147 | } 148 | } 149 | // 需要权限的路由,注意首尾不能带有斜杠 150 | const authRoutes = ['pages/home/index', 'pages/profile/index'] 151 | // 全部路由 152 | const routes = ['pages/blank/index', 'pages/index/index', 'pages/home/index', 'pages/profile/index'] 153 | export default Router.instance 154 | -------------------------------------------------------------------------------- /src/api/core/http.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error 2 | import utils from 'axios/unsafe/utils' 3 | import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios' 4 | import axios from 'axios' 5 | import requestConfig from './config' 6 | 7 | type RequestError = AxiosError | Error 8 | 9 | interface IRequestOptions extends AxiosRequestConfig { 10 | skipErrorHandler?: boolean 11 | getResponse?: boolean 12 | requestInterceptors?: IRequestInterceptorTuple[] 13 | responseInterceptors?: IResponseInterceptorTuple[] 14 | [key: string]: any 15 | } 16 | 17 | interface IRequest { 18 | (url: string, opts?: IRequestOptions): Promise 19 | } 20 | 21 | interface IUpload { 22 | (url: string, data: D, opts?: IRequestOptions): Promise 23 | } 24 | 25 | interface IErrorHandler { 26 | (error: RequestError, opts: IRequestOptions): void 27 | } 28 | 29 | type IRequestInterceptor = ( 30 | config: IRequestOptions & InternalAxiosRequestConfig 31 | ) => IRequestOptions & InternalAxiosRequestConfig 32 | type IResponseInterceptor = (response: AxiosResponse) => AxiosResponse 33 | type IErrorInterceptor = (error: AxiosError) => Promise 34 | 35 | type IRequestInterceptorTuple = [IRequestInterceptor, IErrorInterceptor] | [IRequestInterceptor] | IRequestInterceptor 36 | type IResponseInterceptorTuple = 37 | | [IResponseInterceptor, IErrorInterceptor] 38 | | [IResponseInterceptor] 39 | | IResponseInterceptor 40 | 41 | interface RequestConfig extends AxiosRequestConfig { 42 | errorConfig?: { 43 | errorHandler?: IErrorHandler 44 | errorThrower?: (res: T) => void 45 | } 46 | requestInterceptors?: IRequestInterceptorTuple[] 47 | responseInterceptors?: IResponseInterceptorTuple[] 48 | } 49 | 50 | const singletonEnforcer = Symbol('AxiosRequest') 51 | 52 | class AxiosRequest { 53 | private static _instance: AxiosRequest 54 | private readonly service: AxiosInstance 55 | private config: RequestConfig = { 56 | // TODO 改成你的基础路径 57 | baseURL: process.env.TARO_APP_API, 58 | timeout: 10000, 59 | headers: { 60 | 'Content-Type': 'application/json;charset=utf-8', 61 | }, 62 | } 63 | 64 | constructor(enforcer: any) { 65 | if (enforcer !== singletonEnforcer) 66 | throw new Error('Cannot initialize Axios client single instance') 67 | 68 | this.mergeConfig() 69 | this.service = axios.create(this.config) 70 | // 请求拦截 71 | this.config?.requestInterceptors?.forEach((interceptor) => { 72 | Array.isArray(interceptor) 73 | ? this.service.interceptors.request.use(interceptor[0], interceptor[1]) 74 | : this.service.interceptors.request.use(interceptor) 75 | }) 76 | // 响应拦截 77 | this.config?.responseInterceptors?.forEach((interceptor) => { 78 | Array.isArray(interceptor) 79 | ? this.service.interceptors.response.use(interceptor[0], interceptor[1]) 80 | : this.service.interceptors.response.use(interceptor) 81 | }) 82 | } 83 | 84 | /** 85 | * 创建唯一实例 86 | */ 87 | static get instance() { 88 | // 如果已经存在实例则直接返回, 否则实例化后返回 89 | this._instance || (this._instance = new AxiosRequest(singletonEnforcer)) 90 | return this._instance 91 | } 92 | 93 | /** 94 | * 合并请求参数 95 | */ 96 | private mergeConfig() { 97 | this.config = utils.merge(this.config, requestConfig) 98 | } 99 | 100 | /** 101 | * 获取需要移除的拦截器 102 | * @param opts 103 | */ 104 | private getInterceptorsEject(opts: { 105 | requestInterceptors?: IRequestInterceptorTuple[] 106 | responseInterceptors?: IResponseInterceptorTuple[] 107 | }) { 108 | const { requestInterceptors, responseInterceptors } = opts 109 | const requestInterceptorsToEject = requestInterceptors?.map((interceptor) => { 110 | return Array.isArray(interceptor) 111 | ? this.service.interceptors.request.use(interceptor[0], interceptor[1]) 112 | : this.service.interceptors.request.use(interceptor) 113 | }) 114 | const responseInterceptorsToEject = (responseInterceptors as IResponseInterceptorTuple[])?.map((interceptor) => { 115 | return Array.isArray(interceptor) 116 | ? this.service.interceptors.response.use(interceptor[0], interceptor[1]) 117 | : this.service.interceptors.response.use(interceptor) 118 | }) 119 | return { requestInterceptorsToEject, responseInterceptorsToEject } 120 | } 121 | 122 | /** 123 | * 移除拦截器 124 | * @param opts 125 | */ 126 | private removeInterceptors(opts: { requestInterceptorsToEject?: number[], responseInterceptorsToEject?: number[] }) { 127 | const { requestInterceptorsToEject, responseInterceptorsToEject } = opts 128 | requestInterceptorsToEject?.forEach((interceptor) => { 129 | this.service.interceptors.request.eject(interceptor) 130 | }) 131 | responseInterceptorsToEject?.forEach((interceptor) => { 132 | this.service.interceptors.response.eject(interceptor) 133 | }) 134 | } 135 | 136 | /** 137 | * 基础请求 138 | * @param url 接口地址 139 | * @param opts 请求参数 140 | */ 141 | request: IRequest = (url: string, opts = { method: 'GET' }) => { 142 | const { getResponse = false, requestInterceptors, responseInterceptors } = opts 143 | const { requestInterceptorsToEject, responseInterceptorsToEject } = this.getInterceptorsEject({ 144 | requestInterceptors, 145 | responseInterceptors, 146 | }) 147 | return new Promise((resolve, reject) => { 148 | this.service 149 | .request({ ...opts, url }) 150 | .then((res) => { 151 | this.removeInterceptors({ requestInterceptorsToEject, responseInterceptorsToEject }) 152 | resolve(getResponse ? res : res.data) 153 | }) 154 | .catch((error) => { 155 | this.removeInterceptors({ requestInterceptorsToEject, responseInterceptorsToEject }) 156 | try { 157 | const handler = this.config?.errorConfig?.errorHandler 158 | if (handler) 159 | handler(error, opts) 160 | } 161 | catch (e) { 162 | reject(e) 163 | } 164 | finally { 165 | reject(error) // 如果不想把错误传递到方法调用处的话就去掉这个 finally 166 | } 167 | }) 168 | }) 169 | } 170 | 171 | /** 172 | * 上传 173 | * @param url 接口地址 174 | * @param opts 请求参数 175 | */ 176 | upload: IUpload = (url: string, data, opts = {}) => { 177 | opts.headers = opts.headers ?? { 'Content-Type': 'multipart/form-data' } 178 | const { getResponse = false, requestInterceptors, responseInterceptors } = opts 179 | const { requestInterceptorsToEject, responseInterceptorsToEject } = this.getInterceptorsEject({ 180 | requestInterceptors, 181 | responseInterceptors, 182 | }) 183 | return new Promise((resolve, reject) => { 184 | this.service 185 | .post(url, data, opts) 186 | .then((res) => { 187 | this.removeInterceptors({ requestInterceptorsToEject, responseInterceptorsToEject }) 188 | resolve(getResponse ? res : res.data) 189 | }) 190 | .catch((error) => { 191 | this.removeInterceptors({ requestInterceptorsToEject, responseInterceptorsToEject }) 192 | try { 193 | const handler = this.config?.errorConfig?.errorHandler 194 | if (handler) 195 | handler(error, opts) 196 | } 197 | catch (e) { 198 | reject(e) 199 | } 200 | finally { 201 | reject(error) 202 | } 203 | }) 204 | }) 205 | } 206 | 207 | /** 208 | * 下载 209 | * @param url 资源地址 210 | * @param opts 请求参数 211 | */ 212 | download: IRequest = (url: string, opts = {}) => { 213 | opts.responseType = opts.responseType ?? 'blob' 214 | const { getResponse = false, requestInterceptors, responseInterceptors } = opts 215 | const { requestInterceptorsToEject, responseInterceptorsToEject } = this.getInterceptorsEject({ 216 | requestInterceptors, 217 | responseInterceptors, 218 | }) 219 | return new Promise((resolve, reject) => { 220 | this.service 221 | .get(url, opts) 222 | .then((res) => { 223 | this.removeInterceptors({ requestInterceptorsToEject, responseInterceptorsToEject }) 224 | resolve(getResponse ? res : res.data) 225 | }) 226 | .catch((error) => { 227 | this.removeInterceptors({ requestInterceptorsToEject, responseInterceptorsToEject }) 228 | try { 229 | const handler = this.config?.errorConfig?.errorHandler 230 | if (handler) 231 | handler(error, opts) 232 | } 233 | catch (e) { 234 | reject(e) 235 | } 236 | finally { 237 | reject(error) 238 | } 239 | }) 240 | }) 241 | } 242 | } 243 | 244 | const requestInstance = AxiosRequest.instance 245 | const request = requestInstance.request 246 | const upload = requestInstance.upload 247 | const download = requestInstance.download 248 | export { requestInstance, request, upload, download } 249 | export type { 250 | AxiosInstance, 251 | AxiosRequestConfig, 252 | AxiosResponse, 253 | RequestError, 254 | RequestConfig, 255 | IResponseInterceptor as ResponseInterceptor, 256 | IRequestOptions as RequestOptions, 257 | IRequest as Request, 258 | IUpload as Upload, 259 | } 260 | -------------------------------------------------------------------------------- /src/libs/utils.ts: -------------------------------------------------------------------------------- 1 | interface AnyObj { 2 | [key: string]: any 3 | } 4 | const { getPrototypeOf } = Object 5 | const kindOf = (cache => (thing: any) => { 6 | const str = toString.call(thing) 7 | return cache[str] || (cache[str] = str.slice(8, -1).toLowerCase()) 8 | })(Object.create(null)) 9 | function kindOfTest(type: string) { 10 | type = type.toLowerCase() 11 | return (thing: any) => kindOf(thing) === type 12 | } 13 | const typeOfTest = (type: string) => (thing: any) => typeof thing === type 14 | function findKey(obj: object, key: string) { 15 | key = key.toLowerCase() 16 | const keys = Object.keys(obj) 17 | let i = keys.length 18 | let _key 19 | while (i-- > 0) { 20 | _key = keys[i] 21 | if (key === _key.toLowerCase()) 22 | return _key 23 | } 24 | return null 25 | } 26 | const _global = (() => { 27 | if (typeof globalThis !== 'undefined') 28 | return globalThis 29 | return typeof self !== 'undefined' ? self : typeof window !== 'undefined' ? window : global 30 | })() 31 | 32 | const singletonEnforcer = Symbol('Utils') 33 | // 助手函数写这里 34 | class Utils { 35 | private static _instance: Utils 36 | constructor(enforcer: any) { 37 | if (enforcer !== singletonEnforcer) 38 | throw new Error('Cannot initialize single instance') 39 | } 40 | 41 | static get instance() { 42 | // 如果已经存在实例则直接返回, 否则实例化后返回 43 | this._instance || (this._instance = new Utils(singletonEnforcer)) 44 | return this._instance 45 | } 46 | 47 | /** @description 是否为数组 */ 48 | isArray = Array.isArray 49 | /** @description 是否为 undefined */ 50 | isUndefined = typeOfTest('undefined') 51 | /** @description 是否为对象 */ 52 | isObject = (thing: any) => thing !== null && typeof thing === 'object' 53 | /** @description 是否为函数 */ 54 | isFunction = typeOfTest('function') 55 | /** @description 是否为数字 */ 56 | isNumber = typeOfTest('number') 57 | /** @description 是否为布尔值 */ 58 | isBoolean = (thing: any) => thing === true || thing === false 59 | /** @description 是否为字符串 */ 60 | isString = typeOfTest('string') 61 | /** @description 是否为 Date 对象 */ 62 | isDate = kindOfTest('Date') 63 | /** @description 是否为 File 对象 */ 64 | isFile = kindOfTest('File') 65 | /** @description 是否为 FileList 对象 */ 66 | isFileList = kindOfTest('FileList') 67 | /** @description 是否为 Blob 对象 */ 68 | isBlob = kindOfTest('Blob') 69 | /** @description 是否为 Stream流 */ 70 | isStream = (val: any) => this.isObject(val) && this.isFunction(val.pipe) 71 | /** @description 是否为 URLSearchParams 对象 */ 72 | isURLSearchParams = kindOfTest('URLSearchParams') 73 | /** @description 是否为 HTMLFormElement 对象 */ 74 | isHTMLForm = kindOfTest('HTMLFormElement') 75 | /** @description 是否为 ArrayBuffer 对象 */ 76 | isArrayBuffer = kindOfTest('ArrayBuffer') 77 | /** @description 是否为 RegExp 对象 */ 78 | isRegExp = kindOfTest('RegExp') 79 | /** @description 是否为异步函数 */ 80 | isAsyncFn = kindOfTest('AsyncFunction') 81 | /** @description 是否存在上下文对象 */ 82 | isContextDefined = (context: any) => !this.isUndefined(context) && context !== _global 83 | /** @description 是否为 Buffer 对象 */ 84 | isBuffer(val: any) { 85 | return ( 86 | val !== null 87 | && !this.isUndefined(val) 88 | && val.constructor !== null 89 | && !this.isUndefined(val.constructor) 90 | && this.isFunction(val.constructor.isBuffer) 91 | && val.constructor.isBuffer(val) 92 | ) 93 | } 94 | 95 | /** @description 是否为 ArrayBuffer 对象 */ 96 | isArrayBufferView(val: any): boolean { 97 | let result 98 | if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView) 99 | result = ArrayBuffer.isView(val) 100 | else 101 | result = val && val.buffer && this.isArrayBuffer(val.buffer) 102 | 103 | return result 104 | } 105 | 106 | /** @description 是否为 plain object */ 107 | isPlainObject = (val: any) => { 108 | if (kindOf(val) !== 'object') 109 | return false 110 | const prototype = getPrototypeOf(val) 111 | return ( 112 | (prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null) 113 | && !(Symbol.toStringTag in val) 114 | && !(Symbol.iterator in val) 115 | ) 116 | } 117 | 118 | /** @description 是否为 FormData 对象 */ 119 | isFormData = (thing: any) => { 120 | let kind 121 | return ( 122 | thing 123 | && ((typeof FormData === 'function' && thing instanceof FormData) 124 | || (this.isFunction(thing.append) 125 | && ((kind = kindOf(thing)) === 'formdata' 126 | // detect form-data instance 127 | || (kind === 'object' && this.isFunction(thing.toString) && thing.toString() === '[object FormData]')))) 128 | ) 129 | } 130 | 131 | /** @description 是否为 FormData 对象 */ 132 | isSpecCompliantForm(thing: any) { 133 | return !!( 134 | thing 135 | && this.isFunction(thing.append) 136 | && thing[Symbol.toStringTag] === 'FormData' 137 | && thing[Symbol.iterator] 138 | ) 139 | } 140 | 141 | /** @description 是否有 then 方法 */ 142 | isThenable = (thing: any) => 143 | thing 144 | && (this.isObject(thing) || this.isFunction(thing)) 145 | && this.isFunction(thing.then) 146 | && this.isFunction(thing.catch) 147 | 148 | /** @description 是否绝对地址 */ 149 | isAbsoluteURL(url: string) { 150 | return /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url) 151 | } 152 | 153 | /** @description 去除字符串首尾的空白符 */ 154 | trim = (str: string) => (str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, '')) 155 | /** @description 去除字符串中的 BOM */ 156 | stripBOM = (content: string) => { 157 | if (content.charCodeAt(0) === 0xFEFF) 158 | content = content.slice(1) 159 | 160 | return content 161 | } 162 | 163 | /** @description 把 横线、下划线、空格 连接起来的字符串转为小驼峰字符串 */ 164 | toCamelCase = (str: string) => { 165 | return str.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g, (m, p1, p2) => { 166 | return p1.toUpperCase() + p2 167 | }) 168 | } 169 | 170 | /** @description 判断对象是否有某属性 */ 171 | hasOwnProperty = ( 172 | ({ hasOwnProperty }) => 173 | (obj: object, prop: string) => 174 | hasOwnProperty.call(obj, prop) 175 | )(Object.prototype) 176 | 177 | /** @description 把baseURL和relativeURL组合起来 */ 178 | combineURLs(baseURL: string, relativeURL: string) { 179 | return relativeURL ? `${baseURL.replace(/\/+$/, '')}/${relativeURL.replace(/^\/+/, '')}` : baseURL 180 | } 181 | 182 | /** @description 将类数组对象转为真正的数组 */ 183 | toArray = (thing: any) => { 184 | if (!thing) 185 | return null 186 | if (this.isArray(thing)) 187 | return thing 188 | let i = thing.length 189 | if (!this.isNumber(i)) 190 | return null 191 | const arr = new Array(i) 192 | while (i-- > 0) 193 | arr[i] = thing[i] 194 | 195 | return arr 196 | } 197 | 198 | /** @description 迭代数组或对象 */ 199 | forEach(obj: AnyObj | Array, fn: (...args: any[]) => void) { 200 | if (obj === null || typeof obj === 'undefined') 201 | return 202 | 203 | if (typeof obj !== 'object') 204 | obj = [obj] 205 | 206 | if (this.isArray(obj)) { 207 | for (let i = 0, l = obj.length; i < l; i++) 208 | fn.call(null, obj[i], i, obj) 209 | } 210 | else { 211 | for (const key in obj) { 212 | if (Object.prototype.hasOwnProperty.call(obj, key)) 213 | fn.call(null, obj[key], key, obj) 214 | } 215 | } 216 | } 217 | 218 | /** @description 对象合并 */ 219 | merge(...args: object[]) { 220 | // @ts-expect-error 221 | const { caseless } = (this.isContextDefined(this) && this) || {} 222 | const result: AnyObj = {} 223 | const assignValue = (val: any, key: string) => { 224 | const targetKey = (caseless && findKey(result, key)) || key 225 | if (this.isPlainObject(result[targetKey]) && this.isPlainObject(val)) 226 | result[targetKey] = this.merge(result[targetKey], val) 227 | else if (this.isPlainObject(val)) 228 | result[targetKey] = this.merge({}, val) 229 | else if (this.isArray(val)) 230 | result[targetKey] = val.slice() 231 | else 232 | result[targetKey] = val 233 | } 234 | 235 | for (let i = 0, l = arguments.length; i < l; i++) 236 | args[i] && this.forEach(args[i], assignValue) 237 | 238 | return result 239 | } 240 | 241 | /** @description 将文件对象转为URL */ 242 | readBlob2Url = (blob: Blob, cb: (url: any) => void) => { 243 | if (!this.isBlob(blob)) 244 | throw new Error('is not Blob') 245 | 246 | new Promise((resolve, reject) => { 247 | const reader = new FileReader() 248 | reader.onload = () => resolve(reader.result) 249 | reader.onerror = error => reject(error) 250 | reader.readAsDataURL(blob) 251 | }).then(cb) 252 | } 253 | 254 | /** @description 洗牌算法 */ 255 | shuffle = (arr: any[]) => { 256 | const res: any[] = [] 257 | let random = void 0 258 | while (arr.length > 0) { 259 | random = Math.floor(Math.random() * arr.length) 260 | res.push(arr.splice(random, 1)[0]) 261 | } 262 | return res 263 | } 264 | 265 | /** @description 深拷贝 */ 266 | deepClone = (source: any, cache = new WeakMap()) => { 267 | if (typeof source !== 'object' || source === null) 268 | return source 269 | if (cache.has(source)) 270 | return cache.get(source) 271 | const target = Array.isArray(source) ? [] : {} 272 | Reflect.ownKeys(source).forEach((key) => { 273 | const val = source[key] 274 | if (typeof val === 'object' && val !== null) 275 | target[key] = this.deepClone(val, cache) 276 | else 277 | target[key] = val 278 | }) 279 | return target 280 | } 281 | } 282 | export default Utils.instance 283 | --------------------------------------------------------------------------------