从头开始构建使用Solidstart的SolidJS应用
#javascript #网络开发人员 #typescript #solidjs

因此,您已经学习了SolidJS并构建了一堆演示,但是您还没有使用它创建真实的应用程序。

如果是您,并且您想学习如何使用SolidStart framework构建连接到GraphQl Server的服务器端渲染应用程序以加载和存储数据,则此教程适合您。

在本教程中,您将学习如何创建一个新项目,添加页面,实现身份验证,连接到GraphQl Server以及使用TailWindCSS的样式。

先决条件

在本教程中,我认为您知道SolidJ的基础知识,并且您至少通过SolidStart文档撇去了。

如果您以前从未使用过SolidJ,我鼓励您首先检查the official tutorial,然后继续此处。

我们要建造什么?

要专注于构建SolidStart应用程序的概念和工作流程,我们将构建一个简单的todo应用程序。

该应用程序将有三页:登录页面,注册页面和主页。首页将包含用户列表的列表。用户还应该能够添加新的戒酒,切换戒酒和删除todos。

在启动教程之前,最好在GitHub上检查代码(但这是可选的)。

GraphQl Server

我已经为此应用创建了后端项目。因此,您需要知道的只是GraphQl Server的URL。

要使用它,您必须先运行它。因此,您需要从GitHub克隆该项目并在本地运行。

好吧,这是一个漫长的介绍。如果您准备好了,让我们开始。

创建项目

首先,从终端运行:

mkdir todoapp-solid && cd todoapp-solid
pnpm create solid

您会看到一个入门模板列表。选择with-tailwindcss

? Which template do you want to use? › - Use arrow-keys. Return to submit.
    bare
    hackernews
    todomvc
    with-auth
    with-mdx
    with-prisma
    with-solid-styled
❯   with-tailwindcss
    with-vitest
    with-websocket

此应用程序将使用Typescript并将是服务器端渲染,因此请选择“是”。

✔ Which template do you want to use? › with-tailwindcss
✔ Server Side Rendering? … yes
✔ Use TypeScript? … yes

现在运行pnpm install安装依赖项。当我们使用时,让我们安装我们将在项目中使用的其他依赖项。

pnpm install @urql/core cookie cookie-signature graphql graphql-tag

这些依赖关系与GraphQL和Cookie解析有关。我将向您展示以后使用的地方。

在继续之前,通过运行pnpm run dev运行项目。如果您打开http://localhost:3000/,则应在浏览器中看到此。

Step 1

风格身体

让我们将默认的背景颜色和文本颜色更改为其他东西。我们可以从src/root.tsx做到这一点。

将以下类添加到<Body>

<Body class="bg-gray-900 text-gray-100 antialiased font-roboto">

删除默认的NAV栏链接

启动模板带有根组件中的导航链接列表。我们不需要它们来进行这个项目。因此,将其从src/root.tsx中删除。

<!-- REMOVE THESE -->
<A class="mr-2" href="/">
  Index
</A>
<A href="/about">About</A>

删除不需要的文件

对于此项目,我们不需要有关启动器模板随附的关于页面或计数器组件。因此,让我们删除src/routes/about.tsxsrc/components/Counter.tsx

将初始标记添加到主页

现在,让我们更新主页(src/routes/index.tsx)以具有我们的应用程序的基本布局。

import { Component } from 'solid-js'

const Home: Component = () => {
  return (
    <div class="mt-20 w-full max-w-lg mx-auto">
      <div>TODO: Header component</div>
      <div class="mt-10 flex items-center justify-between">
        <div>
          Hey <span class="font-bold">TODO: USER NAME</span>
        </div>
        <form>
          <button class="cursor-pointer text-gray-200 hover:text-white">
            Log out
          </button>
        </form>
      </div>
      <div class="mt-2.5">
        <div>TODO: AddTodoInput component</div>
        <div class="mt-3.5">
          <div>TODO: when loading show Spinner component</div>
          <div>TODO: when todos are loaded, display them </div>
          <div>TODO: If loaded todos are empty, display empty state</div>
        </div>
      </div>
    </div>
  )
}

export default Home

要知道我们将在此页面上拥有哪些组件,我用TODO: ...标记了他们的位置。这样

创建标题组件

标题组件只是显示应用程序名称和SolidJS徽标的组件(作为一个不错的触摸ð)。

所以,在src/components中创建Header.tsx并将以下内容放入其中。

import { Component } from 'solid-js'

const Logo = () => {
  return (
    <>
      {/* PASTE THE LOGO SVG HERE */}
      {/* https://www.solidjs.com/assets/logo.123b04bc.svg */}
    </>
  )
}

const Header: Component = () => {
  return (
    <h1 class="text-4xl font-bold text-center flex items-center justify-center">
      <div class="mr-2.5 w-10">
        <Logo />
      </div>
      Todo App
    </h1>
  )
}

export default Header

现在让我们通过用<Header />替换<div>TODO: Header component</div>在主页上。

import { Component } from 'solid-js'
import Header from '~/components/Header'

const Home: Component = () => {
  return (
    <div class="mt-20 w-full max-w-lg mx-auto">
      <Header />
        {/* ... */}

如果您现在检查浏览器,则应该看到以下内容:
Step 2

添加登录页面

喜欢主页,我们将首先添加html,然后再用一些todo笔记。

要在SolidStart中添加一个新页面,只需将新组件添加到src/routes文件夹中即可。让我们命名为login.tsx

import { Component } from 'solid-js'
import Header from '~/components/Header'

const Login: Component = () => {
  return (
    <div class="min-h-screen flex flex-col justify-center items-center">
      <Header />
      <div>TODO: Show an error if username or password is incorrect</div>
      <form class="mt-5 w-full max-w-lg mx-auto flex flex-col">
        <input
          class="p-3.5 rounded-t border-b border-gray-300 text-gray-900 outline-none"
          name="username"
          type="text"
          placeholder="Username"
          required
        />
        <input
          class="p-3.5 rounded-b text-gray-900 outline-none"
          name="password"
          type="password"
          placeholder="Password"
          required
        />
        <button class="mt-2.5 py-2.5 rounded bg-blue-500 hover:bg-blue-600">
          Log in
        </button>
      </form>
      <div class="mt-5">
        Don't have an account?
        <a class="text-blue-400 ml-1" href="/signup">
          Sign up here
        </a>
      </div>
    </div>
  )
}

export default Login

如果您转到登录页面(http://localhost:3000/login),则应该看到以下内容:
Step 3

添加注册页面

它应该类似于登录页面。

创建src/routes/signup.tsx并列出以下内容:

import { Component } from 'solid-js'
import Header from '~/components/Header'

const Signup: Component = () => {
  return (
    <div class="min-h-screen flex flex-col justify-center items-center">
      <Header />
      <form class="mt-5 w-full max-w-lg mx-auto flex flex-col">
        <input
          class="p-3.5 rounded-t border-b border-gray-300 text-gray-900 outline-none"
          name="name"
          type="text"
          placeholder="Name"
          required
        />
        <input
          class="p-3.5 border-b border-gray-300 text-gray-900 outline-none"
          name="username"
          type="text"
          placeholder="Username"
          required
        />
        <input
          class="p-3.5 rounded-b text-gray-900 outline-none"
          name="password"
          type="password"
          placeholder="Password"
          required
        />
        <button class="mt-2.5 py-2.5 rounded bg-blue-500 hover:bg-blue-600">
          Sign up
        </button>
      </form>
      <div class="mt-5">
        Already have an account?
        <a class="text-blue-400 ml-1" href="/login">
          Log in here
        </a>
      </div>
    </div>
  )
}

export default Signup

如果您转到http://localhost:3000/signup,则应在浏览器中看到此内容:
Step 4

使用<A />使用客户端路由

现在,我们正在使用本机<a></a>链接到其他页面。它可以正常工作,但是当用户单击它时,它会提出完整的请求(MPA风格)进入链接页面。

我们可以通过将其切换到增强的<A/>版本将其重定向到客户端(SPA风格)上的页面。它使用户体验更快,更光滑。

import { A } from 'solid-start'导入后,您要做的就是将<a>替换为<A>

所以,让我们更新使用它的登录页面和注册页面。

登录:

<A class="text-blue-400 ml-1" href="/signup">
  Sign up here
</A>

注册:

<A class="text-blue-400 ml-1" href="/login">
  Log in here
</A>

我们将如何实施身份验证?

身份验证是一个值得一系列博客文章的重要主题。但是要保持简单,我将说明它在这个应用程序中的工作原理,然后您可以自己理解代码中的详细信息(如果您想提高开发技能,我鼓励它)。

我们将使用JWT令牌进行身份验证。如果您不熟悉它,我建议在继续之前学习它。 (我在这里写了一篇博客文章:Understand JWT in 3 minutes。)

这是我们将如何处理此应用中的身份验证的快速概述:

  1. 用户通过提供其用户名和密码来登录
  2. 我们将用户名和密码发送到我们的GraphQl Server
  3. GraphQl检查它们是否有效(通过将它们与数据库中存储的内容进行比较)
  4. 那么,如果它们有效,则GraphQl Server会生成JWT令牌,并将其返回到响应中的应用
  5. 那么,我们将通过将其嵌入每个GraphQl查询和突变的标题来使用此令牌

现在这看起来很简单,但是当我们想将其存储在客户端时(因此用户不必每次关闭应用程序时)。

我们需要一个服务器和浏览器都可以访问令牌的地方。首先想到的是cookie,这是正确的。但是将其存储在常规的cookie中使我们解决了一些安全问题。避免这些问题的最佳方法是将其存储在仅HTTP的cookie中,这是只能从服务器访问的cookie。

现在的问题是:浏览器如何访问令牌?

记住,我们需要浏览器才能访问令牌以从浏览器发送查询和突变(即Ajax请求)。

答案是通过通过应用程序的API路由(由我们的应用程序的服务器运行)来代理浏览器请求。因此,我们没有将GraphQL请求直接发送到GraphQl Server URL,而是通过服务器上的内部端点发送它,该端点可以从Cookie访问令牌。然后将令牌取用,将其放在请求标题上,然后将请求转发到真实的GraphQl Server。

可以轻松地使用SolidStart的API Routes创建内部服务器端点。

这是对该应用中的身份验证如何工作的简要说明。由于解释身份验证代码的工作方式超出了本教程的范围,我将向您展示如何添加身份验证代码以及如何使用它。

添加GraphQL客户端

您可以使用所需的任何GraphQL客户端。对于这个项目,我将使用URQL

我们将在多个地方使用客户

让我们在src中的名为lib的文件夹中创建它。因此,创建src/lib/urqlClient.ts并将以下内容放入其中(我将在下面解释它包含的内容)。

import { createClient } from '@urql/core'
import { isServer } from 'solid-js/web'

let token = ''

export function setToken(newToken: string) {
  token = newToken
}

export const urqlClient = () => {
  return createClient({
    url: isServer ? import.meta.env.VITE_GRAPHQL_URL : '/api/graphql',
    fetchOptions: () => {
      return {
        headers: {
          authorization: token || ''
        }
      }
    }
  })
}

此模块导出两个功能:setTokenurqlClient

在开始时,当应用程序加载时,将无法为客户端可用,但可以存储在cookie中。因此,我们需要一种设置它后的方法 - 这就是setToken的目的。

urqlClient用于获取GraphQl客户端的实例。它可以从浏览器或服务器中使用。如果它在服务器中使用,则我们需要将其URL直接设置为GraphQl Server(因为它无需在服务器中使用代理,因为它可以访问仅HTTP的cookie)。

请注意,我们如何使用import.meta.env.VITE_GRAPHQL_URL从环境变量中获取URL,我们将存储在.env中,我们将在一点点创建。

如果从浏览器中使用了GraphQL客户端,则我们应该将其URL设置为代理端点,在这种情况下,它将是/api/graphql(您可以随意任命它,但必须匹配API路由的路径路由routes文件夹)。

添加.env

将敏感数据存储为环境变量始终是一个好习惯。因此,让我们在根目录中创建.env并将其放入其中:

VITE_GRAPHQL_URL=http://localhost:4000/graphql
VITE_COOKIE_TOKEN_KEY=solid-todoapp-token
VITE_COOKIE_SECRET_KEY=secret

我们可以轻松地使用import.meta.env.YOUR_VAR_NAME在应用中的任何地方访问它们。

添加GraphQL代理API路由

由于我们使用了/api/graphql作为代理URL,因此我们需要在src/routes/api/graphql.ts中创建API路由。

这是要投入的代码。您不需要了解它的工作原理,而只知道它将从浏览器转发到真正的GrawsQl Server URL(将JWT令牌放在其标题上)。

import { APIEvent, json } from 'solid-start'
import cookieParser from 'cookie'
import cookieSign from 'cookie-signature'

async function parseBody(request: Request) {
  const reader = request.body.getReader()
  const stream = await new ReadableStream({
    start(controller) {
      return pump()
      function pump() {
        return reader.read().then(({ done, value }) => {
          if (done) {
            controller.close()
            return
          }
          controller.enqueue(value)
          return pump()
        })
      }
    }
  })
  return await (await new Response(stream)).json()
}

function getToken(request: Request) {
  const parsedCookies = cookieParser.parse(request.headers.get('Cookie'))
  const unsigned = cookieSign.unsign(
    parsedCookies[import.meta.env.VITE_COOKIE_TOKEN_KEY],
    import.meta.env.VITE_COOKIE_SECRET_KEY
  )
  const decoded = JSON.parse(Buffer.from(unsigned, 'base64').toString('utf8'))
  return decoded.token
}

const proxyGraphqlRequest = async (request: Request) => {
  const payload = await parseBody(request)
  const token = await getToken(request)

  try {
    const resposne = await fetch(import.meta.env.VITE_GRAPHQL_URL, {
      method: 'POST',
      mode: 'cors',
      headers: {
        ...request.headers,
        'content-type': 'application/json',
        authorization: token
      },
      body: JSON.stringify(payload)
    })
    const jsonResponse = await resposne.json()
    return json(jsonResponse)
  } catch (error) {
    console.log('error', error)
  }
}

export const POST = ({ request }: APIEvent) => {
  return proxyGraphqlRequest(request)
}

添加查询和突变

此应用程序的GraphQl Server为我们提供了一个查询和突变列表,我们将用于登录用户,注册用户,添加todos,Update Todos等。

让我们根据src/graphql/将它们添加到我们的项目中。所有查询都将在src/graphql/queries.ts中进行,所有突变都将在src/graphql/mutations.ts中进行。

将以下内容放入src/graphql/queries.ts

import { gql } from 'graphql-tag'

export const CURRENT_USER = gql`
  query currentUser {
    currentUser {
      id
      username
      name
    }
  }
`

export const TODOS = gql`
  query todos {
    todos {
      id
      title
      completed
    }
  }
`

将其纳入src/graphql/mutations.ts

import { gql } from 'graphql-tag'

export const SIGNUP_USER = gql`
  mutation signupUser($input: SignupUserInput!) {
    signupUser(input: $input) {
      status
    }
  }
`

export const LOGIN_USER = gql`
  mutation loginUser($username: String!, $password: String!) {
    loginUser(username: $username, password: $password) {
      token
    }
  }
`

export const UPDATE_TODO = gql`
  mutation updateTodo($input: UpdateTodoInput) {
    updateTodo(input: $input) {
      status
    }
  }
`

export const DELETE_TODO = gql`
  mutation deleteTodo($todoId: ID) {
    deleteTodo(todoId: $todoId) {
      status
    }
  }
`

export const ADD_TODO = gql`
  mutation addTodo($input: AddTodoInput!) {
    addTodo(input: $input) {
      id
      title
      completed
    }
  }
`

在SOLIDSTART中的行动

solidstart为我们提供了处理表单提交的整洁方法。表单提交可以在浏览器端(AJAX)或服务器端完成。

什么决定使用哪个是您用于创建操作的函数。用于服务器提交的createServerAction$,浏览器提交的createRouteAction

调用任何一个返回两个值的数组。第一个包含操作的状态(例如,它正在待处理,或者存在错误)。第二个值包含运行动作的函数。

为了使其变得更好,我们可以从第二个值中访问<Form />,这是表单元素的增强版本。提交表单后,使用它将运行该操作。

集成注册表格

要集成注册表单,我们需要为服务器创建一个操作,以发送GraphQL突变以创建新用户。然后,我们将用我们从操作中获得的<Form />组件替换当前的<form>

集成后的注册页面的完整代码:

import { Component } from 'solid-js'
import { A } from 'solid-start'
import { createServerAction$, redirect } from 'solid-start/server'
import Header from '~/components/Header'
import { SIGNUP_USER } from '~/graphql/mutations'
import { urqlClient } from '~/lib/urqlclient'

const Signup: Component = () => {
  const [_, { Form }] = createServerAction$(async (form: FormData) => {
    const name = form.get('name')
    const username = form.get('username')
    const password = form.get('password')

    const result = await urqlClient()
      .mutation(SIGNUP_USER, { input: { name, username, password } })
      .toPromise()
    if (result) {
      return redirect('/login')
    }
  })

  return (
    <div class="min-h-screen flex flex-col justify-center items-center">
      <Header />
      <Form class="mt-5 w-full max-w-lg mx-auto flex flex-col">
        <input
          class="p-3.5 rounded-t border-b border-gray-300 text-gray-900 outline-none"
          name="name"
          type="text"
          placeholder="Name"
          required
        />
        <input
          class="p-3.5 border-b border-gray-300 text-gray-900 outline-none"
          name="username"
          type="text"
          placeholder="Username"
          required
        />
        <input
          class="p-3.5 rounded-b text-gray-900 outline-none"
          name="password"
          type="password"
          placeholder="Password"
          required
        />
        <button class="mt-2.5 py-2.5 rounded bg-blue-500 hover:bg-blue-600">
          Sign up
        </button>
      </Form>
      <div class="mt-5">
        Already have an account?
        <A class="text-blue-400 ml-1" href="/login">
          Log in here
        </A>
      </div>
    </div>
  )
}

export default Signup

集成登录表单

让我首先向您展示代码并在下面解释。

import { Component, Show } from 'solid-js'
import { A, FormError } from 'solid-start'
import { createServerAction$, redirect } from 'solid-start/server'
import Header from '~/components/Header'
import { login } from '~/session'

const Login: Component = () => {
  const [loggingIn, { Form }] = createServerAction$(async (form: FormData) => {
    const username = form.get('username')
    const password = form.get('password')

    if (typeof username !== 'string' || typeof password !== 'string') {
      throw new FormError('Form data are not correct')
    }

    const headers = await login({ username, password })
    if (headers) {
      return redirect('/', { headers })
    }
  })

  return (
    <div class="min-h-screen flex flex-col justify-center items-center">
      <Header />
      <Show when={loggingIn.error}>
        <div class="mb-3.5 px-3.5 py-2 rounded-lg bg-red-700 text-red-50 text-sm font-medium">
          Your username or password is incorrect
        </div>
      </Show>

      <Form class="mt-5 w-full max-w-lg mx-auto flex flex-col">
        <input
          class="p-3.5 rounded-t border-b border-gray-300 text-gray-900 outline-none"
          name="username"
          type="text"
          placeholder="Username"
          required
        />
        <input
          class="p-3.5 rounded-b text-gray-900 outline-none"
          name="password"
          type="password"
          placeholder="Password"
          required
        />
        <button class="mt-2.5 py-2.5 rounded bg-blue-500 hover:bg-blue-600">
          Log in
        </button>
      </Form>
      <div class="mt-5">
        Don't have an account?
        <A class="text-blue-400 ml-1" href="/signup">
          Sign up here
        </A>
      </div>
    </div>
  )
}

export default Login

我们创建了类似于注册页面的服务器操作,但是这里的主要区别是我们在此处没有使用GraphQl客户端。相反,我们正在从一个名为session.ts的文件中调用login()函数,我们尚未创建。

原因是因为登录涉及更多仅发送GraphQl请求的内容。例如,用户登录后,我们需要将令牌存储在cookie中。

我们还可以在session.ts中添加与身份验证相关的其他内容,例如getUser以获取当前的用户数据,以及logout

为了完成登录页面,创建src/session.ts并将其放入其中。

import { createCookieSessionStorage, FormError, redirect } from 'solid-start'
import { LOGIN_USER } from './graphql/mutations'
import { CURRENT_USER } from './graphql/queries'
import { setToken, urqlClient } from './lib/urqlclient'

const storage = createCookieSessionStorage({
  cookie: {
    name: import.meta.env.VITE_COOKIE_TOKEN_KEY,
    secure: true,
    secrets: [import.meta.env.VITE_COOKIE_SECRET_KEY],
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 24 * 30,
    httpOnly: true
  }
})

interface LoginForm {
  username: string
  password: string
}

export const login = async ({ username, password }: LoginForm) => {
  const result = await urqlClient()
    .mutation(LOGIN_USER, { username, password })
    .toPromise()

  if (!result.data?.loginUser?.token) {
    throw new FormError('Wrong username or password')
  }

  const token = result.data.loginUser.token

  const session = await storage.getSession()
  session.set('token', token)
  return {
    'Set-Cookie': await storage.commitSession(session)
  }
}

export const getUser = async (request: Request) => {
  const session = await storage.getSession(request.headers.get('Cookie'))
  const token = session.get('token')
  if (!token) {
    return null
  }

  setToken(token)

  const result = await urqlClient().query(CURRENT_USER, {}).toPromise()

  if (!result.data?.currentUser) {
    return redirect('/login')
  }
  return result.data.currentUser
}

export const logout = async (request: Request) => {
  const session = await storage.getSession(request.headers.get('Cookie'))
  return redirect('/login', {
    headers: {
      'Set-Cookie': await storage.destroySession(session)
    }
  })
}

添加路线警卫

由路线警卫,我的意思是防止已登录的用户进入登录页和注册页面,并防止访客用户进入主页。

为此,我们需要使用src/sessions.tsgetUser()获取用户,并查看它是否返回用户数据。如果不是,则表示用户未登录。

要使重定向起作用,我们需要在页面渲染之前获取用户,换句话说,在服务器端获取用户。

我们可以在routeData中使用createServerData$轻松地做到这一点。

让我们在主页上使用它,以便将用户重定向到登录页面,如果尚未登录。

const Home: Component = () => {上方的src/routes/index.tsx的顶部添加它:

import { Component } from 'solid-js'
import { redirect } from 'solid-start'
import { createServerData$ } from 'solid-start/server'
import Header from '~/components/Header'
import { getUser } from '~/session'

export function routeData() {
  const user = createServerData$(async (_, { request }) => {
    const user = await getUser(request)

    if (!user) {
      throw redirect('/login')
    }
    return user
  })

  return { user }
}

您现在可以在浏览器中对其进行测试。尝试转到http://localhost:3000,您应该自动重定向http://localhost:3000/login

请注意,您应该首先登录,您可以通过清除cookie手动进行操作 - 我们稍后会实现注销按钮。

现在,让我们添加登录和注册路线警卫。

登录路线护罩类似于主页,除了我们登录用户时,我们重定向到主页。

更新src/routes/login.tsx

import { Component, Show } from 'solid-js'
import { A, FormError, useRouteData } from 'solid-start'
import {
  createServerAction$,
  createServerData$,
  redirect
} from 'solid-start/server'
import Header from '~/components/Header'
import { getUser, login } from '~/session'

export function routeData() {
  return createServerData$(async (_, { request }) => {
    if (await getUser(request)) {
      throw redirect('/')
    }
    return {}
  })
}

const Login: Component = () => {
  const user = useRouteData<typeof routeData>()
  user()
  // ...

这里要注意的一个重要的事情是,我们必须在组件开头调用user()。我们需要这样做,因为路由数据在明确称为

之前不会被获取。

让我们在注册页面中添加相同的东西。

更新src/routes/signup.tsx

import { Component } from 'solid-js'
import { A, useRouteData } from 'solid-start'
import {
  createServerAction$,
  createServerData$,
  redirect
} from 'solid-start/server'
import Header from '~/components/Header'
import { urqlClient } from '~/lib/urqlclient'
import { getUser } from '~/session'
import { SIGNUP_USER } from '../graphql/mutations'

export function routeData() {
  return createServerData$(async (_, { request }) => {
    if (await getUser(request)) {
      throw redirect('/')
    }
    return {}
  })
}

const Signup: Component = () => {
  const user = useRouteData<typeof routeData>()
  user()
  // ...

集成注销

我们在主页上有一个注销按钮,但它还没有任何作用。让我们通过添加一个从src/session.ts调用logout()的服务器操作来解决此问题。

所以,更新src/index.tsx

// ...
const Home: Component = () => {
  const [, { Form }] = createServerAction$((f: FormData, { request }) =>
    logout(request)
  )
  // ...

另外,更新以使用<Form />的表单元素。

在主页上显示用户名

我们已经在主页上获取用户,但我们还没有使用它。

通过添加以下方式从路由获取用户数据:

const Home: Component = () => {
  const { user } = useRouteData<typeof routeData>()
  // ...

然后显示用户名代替TODO: USER NAME

<span class="font-bold">{user()?.name}</span>

创建旋转器组件

这是一个简单的SVG组件,我们将用于显示加载指示器。

创建src/components/Spinner.tsx

export default function Spinner() {
  return (
    <div class="w-full">
      {/* By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL */}
      <svg
        viewBox="0 0 38 38"
        xmlns="http://www.w3.org/2000/svg"
        stroke="currentColor"
      >
        <g fill="none" fill-rule="evenodd">
          <g transform="translate(1 1)" stroke-width="2">
            <circle stroke-opacity=".5" cx="18" cy="18" r="18" />
            <path d="M36 18c0-9.94-8.06-18-18-18">
              <animateTransform
                attributeName="transform"
                type="rotate"
                from="0 18 18"
                to="360 18 18"
                dur="1s"
                repeatCount="indefinite"
              />
            </path>
          </g>
        </g>
      </svg>
    </div>
  )
}

创建AddTodoInput组件

此组件将用于显示待办事项的输入,并使用ADD_TODO突变创建烟道。

src/components/AddTodoInput.tsx上创建组件文件,然后将其添加到其中:

import { Component, createEffect, Show } from 'solid-js'
import { createRouteAction } from 'solid-start'
import { urqlClient } from '~/lib/urqlclient'
import Spinner from './Spinner'
import { ADD_TODO } from '../graphql/mutations'

interface AddTodoInputProps {
  refetchTodos: () => void
}

const AddTodoInput: Component<AddTodoInputProps> = (
  props: AddTodoInputProps
) => {
  const [addingTodo, { Form }] = createRouteAction(async (form: FormData) => {
    return await urqlClient()
      .mutation(ADD_TODO, {
        input: {
          title: form.get('title')
        }
      })
      .toPromise()
  })

  createEffect(() => {
    if (addingTodo.result) {
      props.refetchTodos()
    }
  })

  let inputRef: HTMLInputElement

  return (
    <Form
      class="px-5 w-full bg-gray-100 rounded-lg outline-none text-gray-900 flex items-center justify-between"
      onSubmit={(e) => {
        if (!inputRef.value.trim()) e.preventDefault()
        setTimeout(() => (inputRef.value = ''))
      }}
    >
      <input
        class="rounded-lg py-3.5 flex-1 bg-gray-100 outline-none pr-2.5"
        name="title"
        type="text"
        placeholder="What needs to be done?"
        readonly={addingTodo.pending}
        ref={inputRef}
      />
      <Show when={addingTodo.pending}>
        <div class="text-blue-900 w-8">
          <Spinner />
        </div>
      </Show>
    </Form>
  )
}

export default AddTodoInput

这里要注意的两件事。

首先,我们正在使用createRouteAction而不是createServerData$发送突变,因为我们想从浏览器(ajax)发送它。

第二,我们接受了一个称为refetchTodos的道具。在我们添加新的函数之后,这将是一种函数。我们将从主页组件中传递(我们会稍等一下)。

创建待办事项组件

此组件将用于在列表中显示每个待办事项。它还将包含用于切换其completed状态的代码,以及删除自身的代码。

将使用UPDATE_TODO突变进行切换。并使用DELETE_TODO突变删除它。

此组件接受两个道具:todo,它将包含要显示的todo数据和refetchTodos,该数据将在Todos被切换或删除时取消。

这是托迪特部分组件(src/components/TodoItem.tsx)的完整代码:

import { Component, createEffect, Show } from 'solid-js'
import { createRouteAction } from 'solid-start'
import { urqlClient } from '~/lib/urqlclient'
import { DELETE_TODO, UPDATE_TODO } from '../graphql/mutations'

export interface Todo {
  id: string
  title: string
  completed: boolean
}

interface TodoProps {
  todo: {
    id: string
    title: string
    completed: boolean
  }
  refetchTodos: () => void
}

const TodoItem: Component<TodoProps> = (props: TodoProps) => {
  const [deletingTodo, deleteTodo] = createRouteAction(
    async (todoId: string) => {
      return await urqlClient()
        .mutation(DELETE_TODO, {
          todoId: todoId
        })
        .toPromise()
    }
  )

  const [togglingTodo, toggleTodo] = createRouteAction(
    async ({ todoId, completed }: { todoId: string; completed: boolean }) => {
      return await urqlClient()
        .mutation(UPDATE_TODO, {
          input: {
            todoId,
            completed
          }
        })
        .toPromise()
    }
  )

  createEffect(() => {
    if (deletingTodo.result || togglingTodo.result) {
      props.refetchTodos()
    }
  })

  return (
    <div class="group flex items-center justify-between bg-gray-200 rounded-lg px-5 py-3.5 text-gray-900">
      <div class="flex items-center">
        <button
          class={
            'hover:border-blue-500 border-2 w-5 h-5 rounded-full flex items-center justify-center text-white cursor-pointer ' +
            (props.todo.completed
              ? 'bg-blue-500 border-blue-500'
              : 'border-gray-500')
          }
          onClick={() =>
            toggleTodo({
              todoId: props.todo.id,
              completed: !props.todo.completed
            })
          }
        >
          <Show when={props.todo.completed}>
            <svg style="width: 15px; height: 15px" viewBox="0 0 24 24">
              <path
                fill="currentColor"
                d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"
              />
            </svg>
          </Show>
        </button>
        <span
          class={
            (props.todo.completed ? 'line-through text-gray-500' : '') +
            ' ml-2.5'
          }
        >
          {props.todo.title}
        </span>
      </div>

      <button
        class="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-gray-900"
        onClick={() => deleteTodo(props.todo.id)}
      >
        <svg style="width: 24px; height: 24px" viewBox="0 0 24 24">
          <path
            fill="currentColor"
            d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M9,8H11V17H9V8M13,8H15V17H13V8Z"
          />
        </svg>
      </button>
    </div>
  )
}

export default TodoItem

全部获取

让我们在src/routes/index.tsx中更新routeData与用户一起获取Todos。

import { Component, createResource } from 'solid-js'
import { redirect, useRouteData } from 'solid-start'
import { createServerAction$, createServerData$ } from 'solid-start/server'
import Header from '~/components/Header'
import { Todo } from '~/components/TodoItem'
import { TODOS } from '~/graphql/queries'
import { urqlClient } from '~/lib/urqlclient'
import { getUser, logout } from '~/session'

export function routeData() {
  const user = createServerData$(async (_, { request }) => {
    const user = await getUser(request)

    if (!user) {
      throw redirect('/login')
    }
    return user
  })

  const [todos, { refetch }] = createResource<Todo[]>(async () => {
    const { data } = await urqlClient().query(TODOS, {}).toPromise()
    return data.todos
  })

  return { user, todos, refetchTodos: refetch }
}

请注意我们也如何从中返回refetchTodos

接下来,让我们更新组件以使用新字段。

const Home: Component = () => {
  const { user, todos, refetchTodos } = useRouteData<typeof routeData>()
  todos()
    // ...

显示AddTodoInput组件

我们的下一步是使用我们已经创建的<AddTodoInput />组件。

导入它,然后用:
替换<div>TODO: AddTodoInput component</div>

<AddTodoInput refetchTodos={refetchTodos} />

添加新的Todos应该有效,但是您不会看到添加的内容,因为我们尚未显示Todos。这是下一步。

所有显示

展示毒品有三个状态:加载托多斯,在待办事项清单为空或显示待办事项时显示空状态。

要显示加载指示器,请用:
替换<div>TODO: when loading show Spinner component</div>

<Show when={todos.loading}>
  <div class="mt-10 flex items-center justify-center text-xl font-medium">
    <div class="w-5 mr-2.5">
      <Spinner />
    </div>
    Loading Todos
  </div>
</Show>

当我们为用户提供戒酒时,请使用<TodoItem />组件显示它们。因此,用:
替换<div>TODO: when todos are loaded, display them </div>

<Show when={!todos.loading && todos() && todos().length > 0}>
  <div class="space-y-2.5">
    <For each={todos()}>
      {(todo: Todo) => (
        <TodoItem todo={todo} refetchTodos={refetchTodos} />
      )}
    </For>
  </div>
</Show>

对于空状态,用:
替换<div>TODO: If loaded todos are empty, display empty state</div>

<Show when={!todos.loading && !todos().length}>
  <div class="mt-10 text-center text-blue-50 text-opacity-40">
    Your Todo List is Empty
  </div>
</Show>

该应用程序准备就绪

恭喜!您已经创建了一个使用SolidJ和SOCESTART的功能齐全的应用程序。

您应该能够注册一个新用户,登录应用程序,从应用程序登录,添加Todos,Toggle Todos和Delete Todos。

Step 5


感谢您阅读â2k!在Twitter上关注我,以获取有关我最新的博客文章,视频教程和一些很酷的Web开发提示的更新。让我们成为朋友!