从零开始使用create-react-app|从零开始使用create-react-app + react + typescript 完成一个网站

在线示例 以下是一个已经完成的成品,如图所示:
从零开始使用create-react-app|从零开始使用create-react-app + react + typescript 完成一个网站
文章图片

你也可以点击此处查看在线示例。
也许有人咋一看,看到这个网站有些熟悉,没错,这个网站来源于https://jsisweird.com/。我花了三天时间,用create-react-app + react + typescript重构这个网站,与网站效果不同的是,我没有加入任何的动画,并且我添加了中英文切换以及回到顶部的效果。
设计分析 观看整个网站,其实整体的架构也不复杂,就是一个首页,20道问题页面以及一个解析页面构成。这些涉及到的问题也好,标题也罢,其实都是一堆定义好的数据,下面我们来一一查看这些数据的定义:
问题数据的定义 很显然,问题数据是一个对象数组,我们来看结构如下:

export const questions = []; //因为问题本身不需要实现中英文切换,所以我们这里也不需要区分,数组项的结构如:{question:"true + false",answer:["\"truefalse\"","1","NaN","SyntaxError"],correct:"1"},

数据的表示一眼就可以看出来,question代表问题,answer代表回答选项,correct代表正确答案。让我们继续。
解析数据的定义 解析数据,需要进行中英文切换,所以我们用一个对象表示,如下:
export const parseObject = { "en":{ output:"",//输出文本 answer:"",//用户回答文本:[], successMsg:"",//用户回答正确文本 errorMsg:"",//用户回答错误文本 detail:[],//问题答案解析文本 tabs:[],//中英文切换选项数组 title:"",//首页标题文本 startContent:"",//首页段落文本 endContent:"",//解析页段落文本 startBtn:"",//首页开始按钮文本 endBtn:"",//解析页重新开始文本 }, "zh":{ //选项同en属性值一致 } }

更多详情,请查看此处源码。
这其中,由于detail里的数据只是普通文本,我们需要将其转换成HTML字符串,虽然有marked.js这样的库可以帮助我们,但是这里我们的转换规则也比较简单,无需使用marked.js这样的库,因此,我在这里封装了一个简易版本的marked工具函数,如下所示:
export function marked(template) { let result = ""; result = template.replace(/\[.+?\]\(.+?\)/g,word => { const link = word.slice(word.indexOf('(') + 1, word.indexOf(')')); const linkText = word.slice(word.indexOf('[') + 1, word.indexOf(']')); return `${linkText}`; }).replace(/\*\*\*([\s\S]*?)\*\*\*[\s]?/g,text => '' + text.slice(3,text.length - 4) + ''); return result; }

转换规则也比较简单,就是匹配a标签以及code标签,这里我们写的是类似markdown的语法。比如a标签的写法应该是如下所示:
[xxx](xxx)

所以以上的转换函数,我们匹配的就是这种结构的字符串,其正则表达式结构如:
/\[.+?\]\(.+?\)/g;

这其中.+?表示匹配任意的字符,这个正则表达式就不言而喻了。除此之外,我们匹配代码高亮的markdown的语法定义如下:
***//code***

为什么我要如此设计?这是因为如果我也使用markdown三个模板字符串符号来定义代码高亮,会和js的模板字符串起冲突,所以为了不必要的麻烦,我改用了三个*来表示,所以以上的正则表达式才会匹配*。如下:
/\*\*\*([\s\S]*?)\*\*\*[\s]?/g

那么以上的正则表达式应该如何理解呢?首先,我们需要确定的是\s以及\S代表什么意思,*在正则表达式中需要转义,所以加了\,这个正则表达式的意思就是匹配***//code***这样的结构。
以上的源码可以查看此处。
其它文本的定义 还有2处的文本的定义,也就是问题选项的统计以及用户回答问题的统计,所以我们分别定义了2个函数来表示,如下:
export function getCurrentQuestion(lang="en",order= 1,total = questions.length){ return lang === 'en' ? `Question ${ order } of ${ total }` : `第${ order }题,共${ total }题`; } export function getCurrentAnswers(lang = "en",correctNum = 0,total= questions.length){ return lang === 'en' ? `You got ${ correctNum } out of ${ total } correct!` : `共 ${ total }道题,您答对了 ${ correctNum } 道题!`; }

这2个工具函数接受3个参数,第一个参数代表语言类型,默认值是"en"也就是英文模式,第二个代表当前第几题/正确题数,第三个参数代表题的总数。然后根据这几个参数返回一段文本,这个也没什么好说的。
实现思路分析 初始化项目 此处略过。可以参考文档。
基础组件的实现 接下来,我们实际上可以将页面分成三大部分,第一部分即首页,第二部分即问题选项页,第三部分则是问题解析页面,在解析页面由于解析内容过多,所以我们需要一个回到顶部的效果。在提及这三个部分的实现之前,我们首先需要封装一些公共的组件,让我们来一起看一下吧!
中英文选项卡切换组件 不管是首页也好,问题页也罢,我们都会看到右上角有一个中英文切换的选项卡组件,效果自不比多说,让我们来思考一下应该如何实现。首先思考一下DOM结构。我们可以很快就想到结构如下:
en zh

在这里,我们应该知道类名应该会是动态操作的,因为需要添加一个选中效果,暂定类名为active,我在这里使用的是事件代理,将事件代理到父元素tab-container上。并且它的文本也是动态的,因为需要区分中英文。于是我们可以很快写出如下的代码:
import React from "react"; import { parseObject } from '../data/data'; import "../style/lang.css"; export default class LangComponent extends React.Component { constructor(props){ super(props); this.state = { activeIndex:0 }; } onTabHandler(e){ const { nativeEvent } = e; const { classList } = nativeEvent.target; if(classList.contains('tab-item') && !classList.contains('tab-active')){ const { activeIndex } = this.state; let newActiveIndex = activeIndex === 0 ? 1 : 0; this.setState({ activeIndex:newActiveIndex }); this.props.changeLang(newActiveIndex); } } render(){ const { lang } = this.props; const { activeIndex } = this.state; return ({ parseObject[lang]["tabs"].map( (tab,index) => ( { tab } ) ) }) } }

css样式代码如下:
.tab-container { display: flex; align-items: center; justify-content: center; border:1px solid #f2f3f4; border-radius: 5px; position: fixed; top: 15px; right: 15px; } .tab-container > .tab-item { padding: 8px 15px; color: #e7eaec; cursor: pointer; background: linear-gradient(to right,#515152,#f3f3f7); transition: all .3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .tab-container > .tab-item:first-child { border-top-left-radius: 5px; border-bottom-left-radius:5px; } .tab-container > .tab-item:last-child { border-top-right-radius: 5px; border-bottom-right-radius:5px; } .tab-container > .tab-item.tab-active,.tab-container > .tab-item:hover { color: #fff; background: linear-gradient(to right,#53b6e7,#0c6bc9); }

js逻辑,我们可以看到我们通过父组件传递一个lang参数用来确定中英文模式,然后开始访问定义数据上的tabs,即数组,react.js渲染列表通常都是使用map方法。事件代理,我们可以看到我们是通过获取原生事件对象nativeEvent拿到类名,判断元素是否含有tab-item类名,从而确定点击的是子元素,然后调用this.setState更改当前的索引项,用来确定当前是哪项被选中。由于只有两项,所以我们可以确定当前索引项不是0就是1,并且我们也暴露了一个事件changeLang给父元素以便父元素可以实时的知道语言模式的值。
至于样式,都是比较基础的样式,没有什么好说的,需要注意的就是我们是使用固定定位将选项卡组件固定在右上角的。以上的源码可以查看此处。
接下来,我们来看第二个组件的实现。
底部内容组件 底部内容组件比较简单,就是一个标签包裹内容。代码如下:
import React from "react"; import "../style/bottom.css"; const BottomComponent = (props) => { return ( { props.children } ) } export default BottomComponent;

CSS代码如下:
.bottom { position: fixed; bottom: 5px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 18px; }

也就是函数组件的写法,采用固定定位定位在底部。以上的源码可以查看此处。让我们看下一个组件的实现。
内容组件的实现 该组件的实现也比较简单,就是用p标签包装了一下。如下:
import React from "react"; import "../style/content.css"; const ContentComponent = (props) => { return ({ props.children }
) } export default ContentComponent;

CSS样式代码如下:
.content { max-width: 35rem; width: 100%; line-height: 1.8; text-align: center; font-size: 18px; color: #fff; }

以上的源码可以查看此处。让我们看下一个组件的实现。
渲染HTML字符串的组件 这个组件其实也就是利用了react.jsdangerouslySetInnerHTML属性来渲染html字符串的。代码如下:
import "../style/render.css"; export function createMarkup(template) { return { __html: template }; } const RenderHTMLComponent = (props) => { const { template } = props; let renderTemplate = typeof template === 'string' ? template : ""; return ; } export default RenderHTMLComponent;

CSS样式代码如下:
.render-content a,.render-content{ color: #fff; } .render-content a { border-bottom:1px solid #fff; text-decoration: none; } .render-content code { color: #245cd4; background-color: #e5e2e2; border-radius: 5px; font-size: 16px; display: block; white-space: pre; padding: 15px; margin: 15px 0; word-break: break-all; overflow: auto; } .render-content a:hover { color:#efa823; border-color: #efa823; }

如代码所示,我们可以看到其实我们就是dangerouslySetInnerHTML属性绑定一个函数,将模板字符串当做参数传入这个函数组件,在函数组件当中,我们返回一个对象,结构即:{ __html:template }。其它也就没有什么好说的。
以上的源码可以查看此处。让我们看下一个组件的实现。
标题组件的实现 标题组件也就是对h1~h6标签的一个封装,代码如下:
import React from "react"; const TitleComponent = (props) => { let TagName = `h${ props.level || 1 }`; return ( { props.children } ) } export default TitleComponent;

整体逻辑也不复杂,就是根据父元素传入的一个level属性从而确定是h1 ~ h6的哪个标签,也就是动态组件的写法。在这里,我们使用了Fragment来包裹了一下组件,关于Fragment组件的用法可以参考文档。我的理解,它就是一个占位标签,由于react.js虚拟DOM的限制需要提供一个根节点,所以这个占位标签的出现就是为了解决这个问题。当然,如果是typescript,我们还需要显示的定义一个类型,如下:
import React, { FunctionComponent,ReactNode }from "react"; interface propType { level:number, children?:ReactNode } //这一行代码是需要的 type HeadingTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; const TitleComponent:FunctionComponent = (props:propType) => { //这里断言一下只能是h1~h6的标签名 let TagName = `h${ props.level }` as HeadingTag; return ( { props.children } ) } export default TitleComponent;

以上的源码可以查看此处。让我们看下一个组件的实现。
按钮组件的实现 按钮组件是一个最基本的组件,它的默认样式肯定是不符合我们的需求的,所以我们需要将它简单的封装一下。如下所示:
import React from "react"; import "../style/button.css"; export default class ButtonComponent extends React.Component { constructor(props){ super(props); this.state = { typeArr:["primary","default","danger","success","info"], sizeArr:["mini",'default',"medium","normal","small"] } } onClickHandler(){ this.props.onClick && this.props.onClick(); } render(){ const { nativeType,type,long,size,className,forwardedRef } = this.props; const { typeArr,sizeArr } = this.state; const buttonType = type && typeArr.indexOf(type) > -1 ? type : 'default'; const buttonSize = size && sizeArr.indexOf(size) > -1 ? size : 'default'; let longClassName = ''; let parentClassName = ''; if(className){ parentClassName = className; } if(long){ longClassName = "long-btn"; } return ( ) } }

CSS样式代码如下:
.btn { padding: 14px 18px; outline: none; display: inline-block; border: 1px solid var(--btn-default-border-color); color: var(--btn-default-font-color); border-radius: 8px; background-color: var(--btn-default-color); font-size: 14px; letter-spacing: 2px; cursor: pointer; } .btn.btn-size-default { padding: 14px 18px; } .btn.btn-size-mini { padding: 6px 8px; } .btn:not(.btn-no-hover):hover,.btn:not(.btn-no-active):active,.btn.btn-active { border-color: var(--btn-default-hover-border-color); background-color: var(--btn-default-hover-color); color:var(--btn-default-hover-font-color); } .btn.long-btn { width: 100%; }

这里对按钮的封装,主要是将按钮分类,通过叠加类名的方式,给按钮加各种类名,从而达到不同类型的按钮的实现。然后暴露一个onClick事件。关于样式代码,这里是通过CSS变量的方式。代码如下:
:root { --btn-default-color:transparent; --btn-default-border-color:#d8dbdd; --btn-default-font-color:#ffffff; --btn-default-hover-color:#fff; --btn-default-hover-border-color:#a19f9f; --btn-default-hover-font-color:#535455; /* 1 */ --bg-first-radial-first-color:rgba(50, 4, 157, 0.271); --bg-first-radial-second-color:rgba(7,58,255,0); --bg-first-radial-third-color:rgba(17, 195, 201,1); --bg-first-radial-fourth-color:rgba(220,78,78,0); --bg-first-radial-fifth-color:#09a5ed; --bg-first-radial-sixth-color:rgba(255,0,0,0); --bg-first-radial-seventh-color:#3d06a3; --bg-first-radial-eighth-color:#7eb4e6; --bg-first-radial-ninth-color:#4407ed; /* 2 */ --bg-second-radial-first-color:rgba(50, 4, 157, 0.41); --bg-second-radial-second-color:rgba(7,58,255,0.1); --bg-second-radial-third-color:rgba(17, 51, 201,1); --bg-second-radial-fourth-color:rgba(220,78,78,0.2); --bg-second-radial-fifth-color:#090ded; --bg-second-radial-sixth-color:rgba(255,0,0,0.1); --bg-second-radial-seventh-color:#0691a3; --bg-second-radial-eighth-color:#807ee6; --bg-second-radial-ninth-color:#07ede1; /* 3 */ --bg-third-radial-first-color:rgba(50, 4, 157, 0.111); --bg-third-radial-second-color:rgba(7,58,255,0.21); --bg-third-radial-third-color:rgba(118, 17, 201, 1); --bg-third-radial-fourth-color:rgba(220,78,78,0.2); --bg-third-radial-fifth-color:#2009ed; --bg-third-radial-sixth-color:rgba(255,0,0,0.3); --bg-third-radial-seventh-color:#0610a3; --bg-third-radial-eighth-color:#c07ee6; --bg-third-radial-ninth-color:#9107ed; /* 4 */ --bg-fourth-radial-first-color:rgba(50, 4, 157, 0.171); --bg-fourth-radial-second-color:rgba(7,58,255,0.2); --bg-fourth-radial-third-color:rgba(164, 17, 201, 1); --bg-fourth-radial-fourth-color:rgba(220,78,78,0.1); --bg-fourth-radial-fifth-color:#09deed; --bg-fourth-radial-sixth-color:rgba(255,0,0,0); --bg-fourth-radial-seventh-color:#7106a3; --bg-fourth-radial-eighth-color:#7eb4e6; --bg-fourth-radial-ninth-color:#ac07ed; }

以上的源码可以查看此处。让我们看下一个组件的实现。
注意:这里的按钮组件样式事实上还没有写完,其它类型的样式因为我们要实现的网站没有用到所以没有去实现。
问题选项组件 实际上就是问题部分页面的实现,我们先来看实际的代码:
import React from "react"; import { QuestionArray } from "../data/data"; import ButtonComponent from './buttonComponent'; import TitleComponent from './titleComponent'; import "../style/quiz-wrapper.css"; export default class QuizWrapperComponent extends React.Component { constructor(props:PropType){ super(props); this.state = {} } onSelectHandler(select){ this.props.onSelect && this.props.onSelect(select); } render(){ const { question } = this.props; return ({ question.question }{ question.answer.map((select,index) => ( { select } )) }) } }

css样式代码如下:
.quiz-wrapper { width: 100%; height: 100vh; padding: 1rem; max-width: 600px; } .App { height: 100vh; overflow:hidden; } .App h1 { color: #fff; font-size: 32px; letter-spacing: 2px; margin-bottom: 15px; text-align: center; } .App .button-wrapper { max-width: 25rem; width: 100%; display: flex; } * { margin: 0; padding: 0; box-sizing: border-box; } body { height:100vh; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background-image: radial-gradient(49% 81% at 45% 47%, var(--bg-first-radial-first-color) 0,var(--bg-first-radial-second-color) 100%), radial-gradient(113% 91% at 17% -2%,var(--bg-first-radial-third-color) 1%,var(--bg-first-radial-fourth-color) 99%), radial-gradient(142% 91% at 83% 7%,var(--bg-first-radial-fifth-color) 1%,var(--bg-first-radial-sixth-color) 99%), radial-gradient(142% 91% at -6% 74%,var(--bg-first-radial-seventh-color) 1%,var(--bg-first-radial-sixth-color) 99%), radial-gradient(142% 91% at 111% 84%,var(--bg-first-radial-eighth-color) 0,var(--bg-first-radial-ninth-color) 100%); animation:background 50s linear infinite; }code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } .mt-10 { margin-top: 10px; } .ml-5 { margin-left: 5px; } .text-align { text-align: center; } .flex-center { display: flex; justify-content: center; align-items: center; } .flex-direction-column { flex-direction: column; } .w-100p { width: 100%; } ::-webkit-scrollbar { width: 5px; height: 10px; background: linear-gradient(45deg,#e9bf89,#c9a120,#c0710a); } ::-webkit-scrollbar-thumb { width: 5px; height: 5px; background: linear-gradient(180deg,#d33606,#da5d4d,#f0c8b8); } @keyframes background { 0% { background-image: radial-gradient(49% 81% at 45% 47%, var(--bg-first-radial-first-color) 0,var(--bg-first-radial-second-color) 100%), radial-gradient(113% 91% at 17% -2%,var(--bg-first-radial-third-color) 1%,var(--bg-first-radial-fourth-color) 99%), radial-gradient(142% 91% at 83% 7%,var(--bg-first-radial-fifth-color) 1%,var(--bg-first-radial-sixth-color) 99%), radial-gradient(142% 91% at -6% 74%,var(--bg-first-radial-seventh-color) 1%,var(--bg-first-radial-sixth-color) 99%), radial-gradient(142% 91% at 111% 84%,var(--bg-first-radial-eighth-color) 0,var(--bg-first-radial-ninth-color) 100%); } 25%,50% { background-image: radial-gradient(49% 81% at 45% 47%, var(--bg-second-radial-first-color) 0,var(--bg-second-radial-second-color) 100%), radial-gradient(113% 91% at 17% -2%,var(--bg-second-radial-third-color) 1%,var(--bg-second-radial-fourth-color) 99%), radial-gradient(142% 91% at 83% 7%,var(--bg-second-radial-fifth-color) 1%,var(--bg-second-radial-sixth-color) 99%), radial-gradient(142% 91% at -6% 74%,var(--bg-second-radial-seventh-color) 1%,var(--bg-second-radial-sixth-color) 99%), radial-gradient(142% 91% at 111% 84%,var(--bg-second-radial-eighth-color) 0,var(--bg-second-radial-ninth-color) 100%); } 50%,75% { background-image: radial-gradient(49% 81% at 45% 47%, var(--bg-third-radial-first-color) 0,var(--bg-third-radial-second-color) 100%), radial-gradient(113% 91% at 17% -2%,var(--bg-third-radial-third-color) 1%,var(--bg-third-radial-fourth-color) 99%), radial-gradient(142% 91% at 83% 7%,var(--bg-third-radial-fifth-color) 1%,var(--bg-third-radial-sixth-color) 99%), radial-gradient(142% 91% at -6% 74%,var(--bg-third-radial-seventh-color) 1%,var(--bg-third-radial-sixth-color) 99%), radial-gradient(142% 91% at 111% 84%,var(--bg-third-radial-eighth-color) 0,var(--bg-third-radial-ninth-color) 100%); } 100% { background-image: radial-gradient(49% 81% at 45% 47%, var(--bg-fourth-radial-first-color) 0,var(--bg-fourth-radial-second-color) 100%), radial-gradient(113% 91% at 17% -2%,var(--bg-fourth-radial-third-color) 1%,var(--bg-fourth-radial-fourth-color) 99%), radial-gradient(142% 91% at 83% 7%,var(--bg-fourth-radial-fifth-color) 1%,var(--bg-fourth-radial-sixth-color) 99%), radial-gradient(142% 91% at -6% 74%,var(--bg-fourth-radial-seventh-color) 1%,var(--bg-fourth-radial-sixth-color) 99%), radial-gradient(142% 91% at 111% 84%,var(--bg-fourth-radial-eighth-color) 0,var(--bg-fourth-radial-ninth-color) 100%); } }

可以看到,我们使用h1标签来显示问题,四个选项都使用的按钮标签,我们将按钮标签选中的是哪一项,通过暴露一个事件onSelect给传递出去。通过使用该组件的时候传递question数据就可以确定一组问题以及选项答案。所以实现效果如下图所示:
从零开始使用create-react-app|从零开始使用create-react-app + react + typescript 完成一个网站
文章图片

这个组件里面可能比较复杂一点的是CSS布局,有采用弹性盒子布局以及背景色渐变动画等等,其它的也没什么好说的。
以上的源码可以查看此处。让我们看下一个组件的实现。
解析组件 解析组件实际上就是解析页面部分的一个封装。我们先来看一下实现效果:
从零开始使用create-react-app|从零开始使用create-react-app + react + typescript 完成一个网站
文章图片

根据上图,我们可以得知解析组件分为六大部分。第一部分首先是对用户回答所作的一个正确统计,实际上就是一个标题组件,第二部分则同样也是一个标题组件,也就是题目信息。第三部分则是正确答案,第四部分则是用户的回答,第五部分则是确定用户回答是正确还是错误,第六部分就是实际的解析。
我们来看一下实现代码:
import React from "react"; import { parseObject,questions } from "../data/data"; import { marked } from "../utils/marked"; import RenderHTMLComponent from './renderHTML'; import "../style/parse.css"; export default class ParseComponent extends React.Component { constructor(props){ super(props); this.state = {}; } render(){ const { lang,userAnswers } = this.props; const setTypeClassName = (index) => `answered-${ questions[index].correct === userAnswers[index] ? 'correctly' : 'incorrectly'}`; return (
    { parseObject[lang].detail.map((content,index) => (
  • {(index + 1)}. { questions[index].question }{ parseObject[lang].output }:{ questions[index].correct }{parseObject[lang].answer }:{userAnswers[index]}{ questions[index].correct === userAnswers[index] ? parseObject[lang].successMsg : parseObject[lang].errorMsg }
  • )) }
) } }

CSS样式代码如下:
.result-wrapper { width: 100%; height: 100%; padding: 60px 15px 40px; overflow-x: hidden; overflow-y: auto; } .result-wrapper .result-list { list-style: none; padding-left: 0; width: 100%; max-width: 600px; } .result-wrapper .result-list .result-item { background-color: #020304; border-radius: 4px; margin-bottom: 2rem; color: #fff; } .result-content .render-content { max-width: 600px; line-height: 1.5; font-size: 18px; } .result-wrapper .result-question { padding:25px; background-color: #1b132b; font-size: 22px; letter-spacing: 2px; border-radius: 4px 4px 0 0; } .result-wrapper .result-question .order { margin-right: 8px; } .result-wrapper .result-item-wrapper,.result-wrapper .result-list .result-item { display: flex; flex-direction: column; } .result-wrapper .result-item-wrapper { padding: 25px; } .result-wrapper .result-item-wrapper .result-user-answer { letter-spacing: 1px; } .result-wrapper .result-item-wrapper .result-correct-answer .result-correct-answer-value, .result-wrapper .result-item-wrapper .result-user-answer .result-user-answer-value { font-weight: bold; font-size: 20px; } .result-wrapper .result-item-wrapper .inline-answer { padding:15px 25px; max-width: 250px; margin:1rem 0; border-radius: 5px; } .result-wrapper .result-item-wrapper .inline-answer.answered-incorrectly { background-color: #d82323; } .result-wrapper .result-item-wrapper .inline-answer.answered-correctly { background-color: #4ee24e; }

可以看到根据我们前面分析的六大部分,我们已经可以确定我们需要哪些组件,首先肯定是渲染一个列表,因为有20道题的解析,并且我们也知道根据传递的lang确定中英文模式。另外一个userAnswers则是用户的回答,根据用户的回答和正确答案做匹配,我们就可以知道用户回答是正确还是错误。这也就是如下这行代码的意义:
const setTypeClassName = (index) => `answered-${ questions[index].correct === userAnswers[index] ? 'correctly' : 'incorrectly'}`;

就是通过索引,确定返回的是正确的类名还是错误的类名,通过类名来添加样式,从而确定用户回答是否正确。我们将以上代码拆分一下,就很好理解了。如下:
1.题目信息
{(index + 1)}. { questions[index].question }

2.正确答案
{ parseObject[lang].output }: { questions[index].correct }

3.用户回答
{parseObject[lang].answer }: {userAnswers[index]}

4.提示信息
{ questions[index].correct === userAnswers[index] ? parseObject[lang].successMsg : parseObject[lang].errorMsg }

5.答案解析
答案解析实际上就是渲染HTML字符串,所以我们就可以通过使用之前封装好的组件。

这个组件完成之后,实际上,我们的整个项目的大部分就已经完成了,接下来就是一些细节的处理。
以上的源码可以查看此处。让我们看下一个组件的实现。
让我们继续,下一个组件的实现也是最难的,也就是回到顶部效果的实现。
回到顶部按钮组件 回到顶部组件的实现思路其实很简单,就是通过监听滚动事件确定回到顶部按钮的显隐状态,当点击回到顶部按钮的时候,我们需要通过定时器以一定增量来进行计算scrollTop,从而达到平滑回到顶部的效果。请看代码如下:
import React, { useEffect } from "react"; import ButtonComponent from "./buttonComponent"; import "../style/top.css"; const TopButtonComponent = React.forwardRef((props, ref) => { const svgRef = React.createRef(); const setPathElementFill = (paths, color) => { if (paths) { Array.from(paths).forEach((path) => path.setAttribute("fill", color)); } }; const onMouseEnterHandler = () => { const svgPaths = svgRef?.current?.children; if (svgPaths) { setPathElementFill(svgPaths, "#2396ef"); } }; const onMouseLeaveHandler = () => { const svgPaths = svgRef?.current?.children; if (svgPaths) { setPathElementFill(svgPaths, "#ffffff"); } }; const onTopHandler = () => { props.onClick && props.onClick(); }; return ( {props.children ? ( props.children) : ()} ); } ); const TopComponent = (props) => { const btnRef = React.createRef(); let scrollElement= null; let top_value = https://www.it610.com/article/0,timer = null; const updateTop = () => { top_value -= 20; scrollElement && (scrollElement.scrollTop = top_value); if (top_value < 0) { if (timer) clearTimeout(timer); scrollElement && (scrollElement.scrollTop = 0); btnRef.current && (btnRef.current.style.display = "none"); } else { timer = setTimeout(updateTop, 1); } }; const topHandler = () => { scrollElement = props.scrollElement?.current || document.body; top_value = https://www.it610.com/article/scrollElement.scrollTop; updateTop(); props.onClick && props.onClick(); }; useEffect(() => { const scrollElement = props.scrollElement?.current || document.body; // listening the scroll event scrollElement && scrollElement.addEventListener("scroll", (e: Event) => { const { scrollTop } = e.target; if (btnRef.current) { btnRef.current.style.display = scrollTop > 50 ? "block" : "none"; } }); }); return (); }; export default TopComponent;

CSS样式代码如下:
.to-Top-btn { position: fixed; bottom: 15px; right: 15px; display: none; transition: all .4s ease-in-out; } .to-Top-btn .icon { width: 35px; height: 35px; }

整个回到顶部按钮组件分为了两个部分,第一个部分我们是使用svg的图标作为回到顶部的点击按钮。首先是第一个组件TopButtonComponent,我们主要做了2个工作,第一个工作就是使用React.forwardRef API来将ref属性进行转发,或者说是将ref属性用于通信。关于这个API的详情可查看文档 forwardRef API。然后就是通过ref属性拿到svg标签下面的所有子元素,通过setAttribute方法来为svg标签添加悬浮改变字体色的功能。这就是以下这个函数的作用:
const setPathElementFill = (paths, color) => { //将颜色值和path标签数组作为参数传入,然后设置fill属性值 if (paths) { Array.from(paths).forEach((path) => path.setAttribute("fill", color)); } };

第二部分就是在钩子函数useEffect中去监听元素的滚动事件,从而确定回到顶部按钮的显隐状态。并且封装了一个更新scrollTop值的函数。
const updateTop = () => { top_value -= 20; scrollElement && (scrollElement.scrollTop = top_value); if (top_value < 0) { if (timer) clearTimeout(timer); scrollElement && (scrollElement.scrollTop = 0); btnRef.current && (btnRef.current.style.display = "none"); } else { timer = setTimeout(updateTop, 1); } };

采用定时器来递归实现动态更改scrollTop。其它也就没有什么好说的呢。
以上的源码可以查看此处。让我们看下一个组件的实现。
app组件的实现 实际上该组件就是将所有封装的公共组件的一个拼凑。我们来看详情代码:
import React, { useReducer, useState } from "react"; import "../style/App.css"; import LangComponent from "../components/langComponent"; import TitleComponent from "../components/titleComponent"; import ContentComponent from "../components/contentComponent"; import ButtonComponent from "../components/buttonComponent"; import BottomComponent from "../components/bottomComponent"; import QuizWrapperComponent from "../components/quizWrapper"; import ParseComponent from "../components/parseComponent"; import RenderHTMLComponent from '../components/renderHTML'; import TopComponent from '../components/topComponent'; import { getCurrentQuestion, parseObject,questions,getCurrentAnswers,QuestionArray } from "../data/data"; import { LangContext, lang } from "../store/lang"; import { OrderReducer, initOrder } from "../store/count"; import { marked } from "../utils/marked"; import { computeSameAnswer } from "../utils/same"; let collectionUsersAnswers [] = []; let collectionCorrectAnswers [] = questions.reduce((v,r) => { v.push(r.correct); return v; },[]); let correctNum = 0; function App() { const [langValue, setLangValue] = useState(lang); const [usersAnswers,setUsersAnswers] = useState(collectionUsersAnswers); const [correctTotal,setCorrectTotal] = useState(0); const [orderState,orderDispatch] = useReducer(OrderReducer,0,initOrder); const changeLangHandler = (index: number) => { const value = https://www.it610.com/article/index === 0 ?"en" : "zh"; setLangValue(value); }; const startQuestionHandler = () => orderDispatch({ type:"reset",payload:1 }); const endQuestionHandler = () => { orderDispatch({ type:"reset",payload:0 }); correctNum = 0; }; const onSelectHandler = (select:string) => { // console.log(select) orderDispatch({ type:"increment"}); if(orderState.count > 25){ orderDispatch({ type:"reset",payload:25 }); } if(select){ collectionUsersAnswers.push(select); } correctNum = computeSameAnswer(correctNum,select,collectionCorrectAnswers,orderState.count); setCorrectTotal(correctNum); setUsersAnswers(collectionUsersAnswers); } const { count:order } = orderState; const wrapperRef = React.createRef(); return ( { order > 0 ? order <= 25 ? ( {getCurrentQuestion(langValue, order)}) : ({ getCurrentAnswers(langValue,correctTotal)} {parseObject[langValue].endBtn} ) : ({parseObject[langValue].title} {parseObject[langValue].startContent} {parseObject[langValue].startBtn} ) } ); } export default App;

以上代码涉及到了一个工具函数,如下所示:
export function computeSameAnswer(correct = 0,userAnswer,correctAnswers,index) { if(userAnswer === correctAnswers[index - 1] && correct <= 25){ correct++; } return correct; }

可以看到,这个函数的作用就是计算用户回答的正确数的。
另外,我们通过使用context.provider来将lang这个值传递给每一个组件,所以我们首先是需要创建一个context如下所示:
import { createContext } from "react"; export let lang = "en"; export const LangContext = createContext(lang);

代码也非常简单,就是调用React.createContext API来创建一个上下文,更多关于这个API的描述可以查看文档。
除此之外,我们还封装了一个reducer函数,如下所示:
export function initOrder(initialCount) { return { count: initialCount }; } export function OrderReducer(state, action) { switch (action.type) { case "increment": return { count: state.count + 1 }; case "decrement": return { count: state.count - 1 }; case "reset": return initOrder(action.payload ? action.payload : 0); default: throw new Error(); } }

这也是react.js的一种数据通信模式,状态与行为(或者说叫载荷),是的我们可以通过调用一个方法来修改数据。比如这一段代码就是这么使用的:
const startQuestionHandler = () => orderDispatch({ type:"reset",payload:1 }); const endQuestionHandler = () => { orderDispatch({ type:"reset",payload:0 }); correctNum = 0; }; const onSelectHandler = (select:string) => { // console.log(select) orderDispatch({ type:"increment"}); if(orderState.count > 25){ orderDispatch({ type:"reset",payload:25 }); } if(select){ collectionUsersAnswers.push(select); } correctNum = computeSameAnswer(correctNum,select,collectionCorrectAnswers,orderState.count); setCorrectTotal(correctNum); setUsersAnswers(collectionUsersAnswers); }

然后就是我们通过一个状态值或者说是数据值order值从而决定页面是渲染哪一部分的页面。order <= 0的时候则是渲染首页,order > 0 && order <= 25的时候则是渲染问题选项页面,order > 25则是渲染解析页面。
以上的源码可以查看此处。
【从零开始使用create-react-app|从零开始使用create-react-app + react + typescript 完成一个网站】关于这个网站,我用vue3.X也实现了一遍,感兴趣可以参考源码。

    推荐阅读