Skip to content
当前页大纲

搭建一个开箱即用的 Taro + React + Zustand + TailwindCSS + TypeScript 工程

UI框架以 NutUI-React 为例

本工程的Github地址

编写此笔记时所使用的Taro版本为3.6.18

相关文档

项目初始化

sh
pnpm install -g @tarojs/cli
taro init taro-react-starter

然后按照提示操作即可

💡

通过上述交互式命令的选项,我们创建了一个带有ESLint的 React 基础工程,接下来我们对它做亿点点额外的配置

配置ESLint和Prettier

这个方案废弃,因为有大佬做了个整合插件,看下面
  • 安装Prettier
sh
pnpm add -D prettier eslint-config-prettier eslint-plugin-prettier

新建.prettierrc.prettierignore文件,填入自己喜欢的配置

json
{
  "$schema": "https://json.schemastore.org/prettierrc",
  "semi": false,
  "tabWidth": 2,
  "printWidth": 120,
  "singleQuote": true,
  "trailingComma": "es5"
}
ini
node_modules
dist
  • .eslintrc改成.eslintrc.js,并填入以下配置
js
module.exports = {
  root: true,
  env: {
    browser: true,
    es2021: true,
    node: true,
  },
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module',
  },
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'taro/react',
    'prettier',
    'plugin:prettier/recommended',
  ],
  plugins: ['@typescript-eslint', 'prettier'],
  overrides: [
    {
      env: {
        node: true,
      },
      files: ['.eslintrc.{js,cjs}'],
      parserOptions: {
        sourceType: 'script',
      },
    },
  ],
  rules: {
    complexity: ['error', 10],
    'prettier/prettier': 'error',
    'react/jsx-uses-react': 'off',
    'react/react-in-jsx-scope': 'off',
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
  },
}

✨新方案,直接使用Nuxt团队的Anthony Fu大佬的eslint-config

sh
pnpm dlx @antfu/eslint-config@latest

编辑eslint.config.mjs

js
import antfu from '@antfu/eslint-config'

export default antfu({
  ignores: ['node_modules', '**/node_modules/**', 'dist', '**/dist/**', '.swc', '**/.swc/**'],
  formatters: true,
  typescript: true,
  react: true,
})

编辑package.json,添加如下内容

json
{
  // ...
  "scripts": {
    // ...
    "lint": "eslint .", 
    "lint:fix": "eslint . --fix" 
  }
}

由于 Anthony Fu 大佬的这套eslint-config默认禁用prettier,如果你想配合prettier一起用的话就安装它(不用的话就跳过),然后在根目录新建.prettierrc,填入自己喜欢的配置

sh
pnpm add -D prettier
json
{
  "$schema": "https://json.schemastore.org/prettierrc",
  "semi": false,
  "tabWidth": 2,
  "printWidth": 120,
  "singleQuote": true,
  "trailingComma": "es5"
}

接着编辑.vscode/settings.json,把prettier启用即可

json
{
  "prettier.enable": true 
  // ...
}

环境变量

详细的文档看这里

根目录新建.env.env.local文件,接着把项目初始化时自动生成的.env.dev重命名为.env.development.env.prod重命名为.env.production,因为根据Taro的开发文档,默认的环境标识是developmentproduction,而env文件的加载规则如下

.env                # 在所有的环境中被载入
.env.local          # 在所有的环境中被载入,但会被 git 忽略
.env.[mode]         # 只在指定的模式中被载入
.env.[mode].local   # 只在指定的模式中被载入,但会被 git 忽略

env文件中的环境变量默认要以TARO_APP_开头,否则不会被加载,之后就可以在src目录下的所有业务逻辑文件中使用process.env.TARO_APP_XXX的形式来读取环境变量值了

安装TailwindCSS

Taro中使用TailwindCSS的文档说明看这里

由于Taro已经内置有postcssautoprefixer,这两个就不用装了

sh
pnpm add -D tailwindcss
npx tailwindcss init -p

编辑生成的tailwind.config.js

js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./public/index.html', './src/**/*.{html,js,ts,jsx,tsx}'], 
  
  corePlugins: {
    preflight: false,
  },
  plugins: [],
}

编辑src/app.scss

scss
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

单位转化插件

sh
pnpm add -D postcss-rem-to-responsive-pixel

编辑postcss.config.js,进行插件的注册

js
const isH5 = process.env.TARO_ENV === 'h5' 
module.exports = {
  plugins: {
    // ... 
    'postcss-rem-to-responsive-pixel': {
      rootValue: 32, // 1rem = 32rpx
      propList: ['*'], // 默认所有属性都转化
      transformUnit: isH5 ? 'px' : 'rpx', // 转化的单位,可以变成 px / rpx
    },
  },
}

安装weapp-tailwindcss

sh
pnpm add -D weapp-tailwindcss
npx weapp-tw patch

编辑package.json

json
{
  "scripts": {
    // ...
    "postinstall": "weapp-tw patch" 
  }
}

编辑config/index.ts,注册weapp-tailwindcss

ts
import { UnifiedWebpackPluginV5 } from 'weapp-tailwindcss/webpack' 
const WeappTailwindcssDisabled = ['h5', 'rn'].includes(process.env.TARO_ENV) 
export default defineConfig(async (merge, { command, mode }) => {
  const baseConfig: UserConfigExport = {
    // ...
    mini: {
      webpackChain(chain) {
        // ... 
        chain.merge({
          plugin: {
            install: {
              plugin: UnifiedWebpackPluginV5,
              args: [{ appType: 'taro', disabled: WeappTailwindcssDisabled }],
            },
          },
        })
      },
    },
  }
})

助手函数

新建src/libs/utils.ts,封装一些辅助函数,具体代码参考我的助手函数封装

请求模块

sh
pnpm add @tarojs/plugin-http axios

编辑config/index.ts,注册插件

ts
export default defineConfig(async (merge, { command, mode }) => {
  const baseConfig: UserConfigExport = {
    // ...
    plugins: ['@tarojs/plugin-http'], 
  }
})

新建src/api/core/http.tssrc/api/core/config.ts,之后的封装逻辑参考我的Axios封装

Mock

sh
pnpm add -D @tarojs/plugin-mock mockjs @types/mockjs

编辑config/dev.ts

ts
export default {
  plugins: ['@tarojs/plugin-mock'],
  h5: {
    devServer: {
      proxy: {
        '/api': {
          target: process.env.TARO_APP_API,
          changeOrigin: true,
          pathRewrite: { '^/api': '' },
        },
      },
    },
  },
} satisfies UserConfigExport

根目录新建mock/index.ts,示例如下,根据自己的情况添加添加接口

ts
export default {
  'POST /api/login': {
    code: '200',
    message: 'ok',
    data: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MjMyODU2LCJzZXNzaW9uIjoiOTRlZTZjOThmMmY4NzgzMWUzNzRmZTBiMzJkYTIwMGMifQ.z5Llnhe4muNsanXQSV-p1DJ-89SADVE-zIkHpM0uoQs',
    success: true,
  },
}

当启动开发服务器的时候就会启动一个数据mock服务器

状态管理

这里用的是Zustand

sh
pnpm add zustand immer

定义

新建src/models/counter.tssrc/models/selectors.ts

ts
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import createSelectors from './selectors'

interface State {
  count: number
}
interface Action {
  inc: () => void
  dec: () => void
}
const initialState: State = {
  count: 0,
}

const counterStore = create<State & Action>()(
  immer((set, get) => ({
    count: 0,
    inc: () => set((state) => ({ count: state.count + 1 })),
    dec: () => set((state) => ({ count: state.count - 1 })),
  }))
)
export const useCounterStore = createSelectors(counterStore)
export function useCounterReset() {
  counterStore.setState(initialState)
}
ts
import { StoreApi, UseBoundStore } from 'zustand'

type WithSelectors<S> = S extends { getState: () => infer T }
  ? S & { use: { [K in keyof T]: () => T[K] } }
  : never

const createSelectors = <S extends UseBoundStore<StoreApi<{}>>>(_store: S) => {
  let store = _store as WithSelectors<typeof _store>
  store.use = {}
  for (let k of Object.keys(store.getState())) {
    ;(store.use as any)[k] = () => store((s) => s[k as keyof typeof s])
  }

  return store
}

export default createSelectors

示例

新建两个页面用来测试,并将其设置为tabbar

sh
taro create home
taro create profile

编辑src/app.config.ts和新建的两个页面

ts
const pages = ['pages/index/index', 'pages/home/index', 'pages/profile/index']
export default defineAppConfig({
  animation: true,
  entryPagePath: 'pages/index/index',
  pages,
  tabBar: {
    color: '#666666',
    selectedColor: '#4965f2',
    backgroundColor: '#fefefe',
    list: [
      {
        pagePath: 'pages/home/index',
        text: '首页',
      },
      {
        pagePath: 'pages/profile/index',
        text: '我的',
      },
    ],
  },
  // ...
})
tsx
import { View, Text, Button } from '@tarojs/components'
import { useCounterStore, useCounterReset } from '@/models'
import './index.scss'
export default function Home() {
  const count = useCounterStore.use.count()
  const inc = useCounterStore.use.inc()
  const dec = useCounterStore.use.dec()
  return (
    <View className="flex flex-1 flex-wrap flex-col items-center justify-center gap-4 h-full">
      <Text>Home Page</Text>
      <View className="text-center">
        <Button type="warn" onClick={dec}>
          Dec
        </Button>
        <Text>{count}</Text>
        <Button type="primary" onClick={inc}>
          Inc
        </Button>
      </View>
      <Button type="default" onClick={useCounterReset}>
        Reset
      </Button>
    </View>
  )
}
tsx
import { View, Text, Button } from '@tarojs/components'
import { useCounterStore } from '@/models'
import './index.scss'
export default function Profile() {
  const { count, inc, dec } = useCounterStore()
  return (
    <View className="flex flex-1 flex-wrap flex-col items-center justify-center gap-4 h-full">
      <Text>Profile Page</Text>
      <View className="text-center">
        <Button type="warn" onClick={dec}>
          Dec
        </Button>
        <Text>{count}</Text>
        <Button type="primary" onClick={inc}>
          Inc
        </Button>
      </View>
    </View>
  )
}

持久化

新建src/libs/storage.tssrc/models/user.ts

tsx
import { setStorageSync, getStorageSync, removeStorageSync } from '@tarojs/taro'
import { StateStorage } from 'zustand/middleware'

enum StorageSceneKey {
  USER = 'storage-user',
}
function getItem<T = any>(key: string): T {
  const value = getStorageSync(key)
  return value ? JSON.parse(value) ?? null : null
}
function setItem(key: string, value: any) {
  setStorageSync(key, JSON.stringify(value))
}
function removeItem(key: string) {
  removeStorageSync(key)
}
export { getItem, setItem, removeItem, StorageSceneKey }

/** @description 用来给 zustand 持久化存储的方法 */
export const zustandStorage: StateStorage = {
  getItem: (key: string) => {
    const value = getStorageSync(key)
    return value ?? null
  },
  setItem: (key: string, value) => {
    setStorageSync(key, value)
  },
  removeItem: (key: string) => {
    removeStorageSync(key)
  },
}
ts
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { createJSONStorage, persist } from 'zustand/middleware'
import createSelectors from './selectors'
import { zustandStorage, StorageSceneKey } from '../libs'

interface State {
  token: string
  isLogged: boolean
}
interface Action {
  setToken: (token: string) => void
  removeToken: () => void
}

const initialState: State = {
  token: '',
  isLogged: false,
}
const store = create<State & Action>()(
  immer(
    persist(
      (set, get) => ({
        token: '',
        isLogged: false,
        setToken: (token) => set({ token, isLogged: true }),
        removeToken: () => set({ token: '', isLogged: false }),
      }),
      {
        //! 注意这里的name是当前这个Zustand模块进行缓存时的唯一key, 每个需要缓存的Zustand模块都必须分配一个唯一key
        name: StorageSceneKey.USER,
        storage: createJSONStorage(() => zustandStorage),
      }
    )
  )
)

export const useUserStore = createSelectors(store)
export function useUserReset() {
  store.setState(initialState)
}

路由权限

①路由状态

新建src/models/auth.ts,用来记录重定向的信息,编辑src/libs/storage.ts,增加一个zustand持久化场景

ts
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { createJSONStorage, persist } from 'zustand/middleware'
import createSelectors from './selectors'
import { zustandStorage, StorageSceneKey } from '../libs'

interface Redirect {
  url: string
  tab?: boolean
}

interface State {
  redirect: Redirect | null
}
interface Action {
  setRedirect: (value: Redirect) => void
  clear: () => void
}

const store = create<State & Action>()(
  immer(
    persist(
      (set, get) => ({
        redirect: null,
        setRedirect: (value) => set({ redirect: value }),
        clear: () => set({ redirect: null }),
      }),
      {
        name: StorageSceneKey.AUTH,
        storage: createJSONStorage(() => zustandStorage),
      }
    )
  )
)

export const useAuthStore = createSelectors(store)
export function useAuthReset() {
  store.setState({ redirect: null })
}
ts
enum StorageSceneKey {
  // ...
  AUTH = 'storage-auth', 
}

②封装Taro的路由跳转

新建src/router/index.ts,对Taro的路由跳转做一层权限控制的封装

查看
ts
import Taro from '@tarojs/taro'
import { useUserStore } from '../models'
import { utils } from '../libs'

interface AnyObj {
  [key: string]: any
}
type RouterType = 'navigateTo' | 'redirectTo' | 'switchTab' | 'reLaunch' | 'navigateBack'
type SuccessCallback =
  | TaroGeneral.CallbackResult
  | (TaroGeneral.CallbackResult & { eventChannel: Taro.EventChannel })
interface TaroRouterOptions<S = SuccessCallback>
  extends Omit<Taro.navigateTo.Option, 'success'>,
    Omit<Taro.navigateBack.Option, 'success'>,
    Omit<Taro.redirectTo.Option, 'success'>,
    Omit<Taro.reLaunch.Option, 'success'>,
    Omit<Taro.switchTab.Option, 'success'> {
  data?: string | AnyObj
  success?: (res: S) => void
}

function searchParams2Obj(params: any) {
  const searchParams = new URLSearchParams(params)
  const obj: AnyObj = {}
  for (const [key, value] of searchParams.entries()) {
    obj[key] = value
  }
  return obj
}

/**
 * 路由跳转处理
 */
function authCheck(urlKey: string, type: RouterType, options: TaroRouterOptions) {
  const isLogged = useUserStore.getState().isLogged
  if (authRoutes.includes(urlKey)) {
    if (!isLogged) {
      // TODO 补充自己的业务逻辑
      return
    }
    navigate(type, options)
  } else {
    navigate(type, options)
  }
}
/**
 * 执行路由跳转
 */
function navigate(type: RouterType, options: TaroRouterOptions) {
  const { data, ...rest } = options
  if (!['navigateTo', 'redirectTo', 'switchTab', 'reLaunch'].includes(type)) return
  if (!rest.url.startsWith('/')) {
    rest.url = `/${rest.url}`
  }
  Taro[type](rest)
}

const singletonEnforcer = Symbol('Router')
class Router {
  private static _instance: Router
  constructor(enforcer: any) {
    if (enforcer !== singletonEnforcer) {
      throw new Error('Cannot initialize single instance')
    }
  }
  static get instance() {
    // 如果已经存在实例则直接返回, 否则实例化后返回
    this._instance || (this._instance = new Router(singletonEnforcer))
    return this._instance
  }
  /**
   * 路由中间件,做跳转前的代理
   */
  private middleware(type: RouterType, options: TaroRouterOptions) {
    let { url = '', data = {}, events, ...rest } = options
    let [urlKey, queryStr] = url.split('?')
    // 单独存一份url,待会要用
    urlKey = urlKey
      .split('/')
      .filter((e) => e !== '')
      .join('/')
    try {
      if (type === 'navigateBack') {
        Taro.navigateBack(rest)
      } else {
        if (!urlKey.trim() || !routes.includes(urlKey)) {
          throw Error('无效的路由')
        }
        if (type === 'switchTab') {
          url = urlKey
        } else {
          let obj: AnyObj = {}
          if (data && typeof data === 'string' && data.trim()) {
            data = searchParams2Obj(data)
          }
          if (queryStr && queryStr.trim()) {
            obj = searchParams2Obj(queryStr)
          }
          const str = new URLSearchParams(utils.merge(data as object, obj)).toString()
          url = str ? `${urlKey}?${str}` : urlKey
        }
        authCheck(urlKey, type, { ...rest, url, events })
      }
    } catch (error) {
      // TODO
      console.error(error.message)
    }
  }
  /**
   * 跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面
   */
  switchTab(options: TaroRouterOptions) {
    this.middleware('switchTab', options)
  }
  /**
   * 关闭所有页面,打开到应用内的某个页面
   */
  reLaunch(options: TaroRouterOptions) {
    this.middleware('reLaunch', options)
  }
  /**
   * 关闭当前页面,跳转到应用内的某个页面。但是不允许跳转到 tabbar 页面
   */
  redirectTo(options: TaroRouterOptions) {
    this.middleware('redirectTo', options)
  }
  /**
   * 保留当前页面,跳转到应用内的某个页面。但是不能跳到 tabbar 页面
   */
  navigateTo(options: TaroRouterOptions) {
    this.middleware('navigateTo', options)
  }
  /**
   * 关闭当前页面,返回上一页面或多级页面
   */
  navigateBack(options: Omit<TaroRouterOptions, 'url'>) {
    this.middleware('navigateBack', { url: '', ...options })
  }
}
// 需要权限的路由,注意首尾不能带有斜杠
const authRoutes = ['pages/home/index', 'pages/profile/index']
// 全部路由
const routes = ['pages/blank/index', 'pages/index/index', 'pages/home/index', 'pages/profile/index']
export default Router.instance

③权限钩子

新建src/hooks/useAuth.ts权限钩子函数,对直接访问 URL 的方式进行拦截

ts
import { useDidShow, getCurrentInstance } from '@tarojs/taro'
import { useUserStore, useAuthStore } from '../models'
import router from '../router'
const tabbar = ['/pages/home/index', '/pages/profile/index']

export const useAuth = () => {
  const isLogged = useUserStore.use.isLogged()
  const setRedirect = useAuthStore.use.setRedirect()
  const current = getCurrentInstance().router
  const path = current ? current.path.split('?')[0] : ''
  const isTab = tabbar.includes(path)
  const routeParams = current?.params
  const params = {}
  for (const [key, value] of Object.entries(routeParams ?? {})) {
    if (!['stamp', '$taroTimestamp'].includes(key)) {
      params[key] = value
    }
  }
  useDidShow(() => {
    if (!isLogged) {
      const str = new URLSearchParams(params).toString()
      setRedirect({ tab: isTab, url: str ? `${path}?${str}` : path })
      router.reLaunch({ url: '/pages/index/index' })
    }
  })
}

④使用示例

新建一个空白页面用来做权限判断的跳板页,并将其设置为启动页面

sh
taro create blank
ts
export default defineAppConfig({
  entryPagePath: 'pages/blank/index', 
  // ...
})

编辑blankindexhome页面

tsx
import { useLoad } from '@tarojs/taro'
import { useUserStore } from '@/models'
import router from '@/router'
export default function Blank() {
  const isLogged = useUserStore.use.isLogged()
  useLoad(() => {
    if (isLogged) {
      router.switchTab({ url: '/pages/home/index' })
    } else {
      router.reLaunch({ url: '/pages/index/index' })
    }
  })
  return null
}
tsx
import { View, Button } from '@tarojs/components'
import { request } from '@/api'
import { useUserStore, useAuthStore, useAuthReset } from '@/models'
import router from '@/router'
import './index.scss'

export default function Index() {
  const setToken = useUserStore.use.setToken()
  const auth = useAuthStore()
  const login = async () => {
    const res = await request('/api/login', {
      method: 'POST',
    })
    setToken(res.data)
    if (auth.redirect?.url) {
      const success = () => {
        useAuthReset()
      }
      auth.redirect.tab
        ? router.switchTab({
            url: auth.redirect.url,
            success,
          })
        : router.redirectTo({
            url: auth.redirect.url,
            success,
          })
    } else {
      router.switchTab({ url: '/pages/home/index' })
    }
  }
  return (
    <View className="flex flex-1 flex-col items-center justify-center gap-2 h-full">
      <Button plain type="primary" onClick={login}>
        Go Home
      </Button>
    </View>
  )
}
tsx
// ...
import { useAuth } from '@/hooks' 
export default function Home() {
  useAuth() 
  // ...
}

UI框架

使用NutUI-React

详细的文档看这里

sh
pnpm add @nutui/nutui-react-taro @nutui/icons-react-taro @tarojs/plugin-html

编辑config/index.ts

ts
import path from 'node:path' 
export default defineConfig(async (merge, { command, mode }) => {
  const baseConfig: UserConfigExport = {
    // ... 
    designWidth: (input: any) => {
      if (input?.file?.replace(/\\+/g, '/').indexOf('@nutui') > -1) {
        return 375
      }
      return 750
    },
    plugins: ['@tarojs/plugin-html'],
    alias: {
      '@': path.resolve(__dirname, '../src'),
    },
    sass: {
      data: '@import "@nutui/nutui-react/dist/styles/variables.scss";',
    },
    mini: {
      postcss: {
        pxtransform: {
          enable: true,
          config: {
            selectorBlackList: ['nut-'],
          },
        },
      },
    },
    h5: {
      esnextModules: ['nutui-react-taro', 'icons-react-taro'],
      postcss: {
        pxtransform: {
          enable: true,
          config: {
            selectorBlackList: ['nut-'],
          },
        },
      },
    },
  }
})

按需引入

sh
pnpm add -D babel-plugin-import

编辑babel.config.js

js
module.exports = {
  // ...
  plugins: [
    [
      'import',
      {
        libraryName: '@nutui/nutui-react-taro',
        libraryDirectory: 'dist/esm',
        style: 'css',
        camel2DashComponentName: false,
      },
      'nutui-react-taro',
    ],
  ],
}

MIT License.