3

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

 3 years ago
source link: https://segmentfault.com/a/1190000040677455
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

发布于 今天 10:54
English

以下是一个已经完成的成品,如图所示:

你也可以点击此处查看在线示例。

也许有人咋一看,看到这个网站有些熟悉,没错,这个网站来源于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 `<a href="${link}" target="blank">${linkText}</a>`;
    }).replace(/\*\*\*([\s\S]*?)\*\*\*[\s]?/g,text => '<code>' + text.slice(3,text.length - 4) + '</code>');
    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结构。我们可以很快就想到结构如下:

<div class="tab-container">
    <div class="tab-item">en</div>
    <div class="tab-item">zh</div>
</div>

在这里,我们应该知道类名应该会是动态操作的,因为需要添加一个选中效果,暂定类名为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 (
            <div className="tab-container" onClick = { this.onTabHandler.bind(this) }>
                {
                    parseObject[lang]["tabs"].map(
                        (tab,index) => 
                        (
                            <div className={`tab-item ${ activeIndex === index ? 'tab-active' : ''}`}  key={tab}>{ tab }</div>
                        )
                    )
                }
            </div>
        )
    }
}

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 (
        <div className="bottom" id="bottom">{ props.children }</div>
    )
}
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 (
        <p className="content">{ props.children }</p>
    )
}
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 <div dangerouslySetInnerHTML={createMarkup( renderTemplate )} className="render-content"></div>;
}
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 (
        <React.Fragment>
            <TagName>{ props.children }</TagName>
        </React.Fragment>
    )
}
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<propType> = (props:propType) => {
    //这里断言一下只能是h1~h6的标签名
    let TagName = `h${ props.level }` as HeadingTag;
    return (
        <React.Fragment>
            <TagName>{ props.children }</TagName>
        </React.Fragment>
    )
}
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 (
            <button
                ref={forwardedRef}
                type={nativeType} 
                className={ `btn btn-${ buttonType } ${ longClassName } btn-size-${buttonSize} ${parentClassName}`} 
                onClick={ this.onClickHandler.bind(this)}
            >{ this.props.children }</button>
        )
    }
}

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 (
            <div className="quiz-wrapper flex-center flex-direction-column">
                <TitleComponent level={1}>{ question.question }</TitleComponent>
                <div className="button-wrapper flex-center flex-direction-column">
                    {
                        question.answer.map((select,index) => (
                            <ButtonComponent 
                                nativeType="button" 
                                onClick={ this.onSelectHandler.bind(this,select)}
                                className="mt-10 btn-no-hover btn-no-active"
                                key={select}
                                long
                            >{ select }</ButtonComponent>
                        ))
                    }
                </div>
            </div>
        )
    }
}

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数据就可以确定一组问题以及选项答案。所以实现效果如下图所示:

这个组件里面可能比较复杂一点的是CSS布局,有采用弹性盒子布局以及背景色渐变动画等等,其它的也没什么好说的。

以上的源码可以查看此处。让我们看下一个组件的实现。

解析组件实际上就是解析页面部分的一个封装。我们先来看一下实现效果:

根据上图,我们可以得知解析组件分为六大部分。第一部分首先是对用户回答所作的一个正确统计,实际上就是一个标题组件,第二部分则同样也是一个标题组件,也就是题目信息。第三部分则是正确答案,第四部分则是用户的回答,第五部分则是确定用户回答是正确还是错误,第六部分就是实际的解析。

我们来看一下实现代码:

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 (
            <ul className="result-list">
                {
                    parseObject[lang].detail.map((content,index) => (
                        <li 
                            className={`result-item ${ setTypeClassName(index) }`} key={content}>
                            <span className="result-question">
                                <span className="order">{(index + 1)}.</span>
                                { questions[index].question }
                            </span>
                            <div className="result-item-wrapper">
                                <span className="result-correct-answer">
                                    { parseObject[lang].output }:<span className="ml-5 result-correct-answer-value">{ questions[index].correct }</span>
                                </span>
                                <span className="result-user-answer">
                                    {parseObject[lang].answer }:<span className="ml-5 result-user-answer-value">{userAnswers[index]}</span>
                                </span>
                                <span 
                                    className={`inline-answer ${ setTypeClassName(index) }`}>
                                    {
                                        questions[index].correct === userAnswers[index] 
                                        ? parseObject[lang].successMsg 
                                        : parseObject[lang].errorMsg
                                    }
                                </span>
                                <RenderHTMLComponent template={ marked(content) }></RenderHTMLComponent>
                            </div>
                        </li>
                    ))
                }
            </ul>
        )
    }
}

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.题目信息

<span className="result-question">
     <span className="order">{(index + 1)}.</span>
     { questions[index].question }
</span>

2.正确答案

 <span className="result-correct-answer">
    { parseObject[lang].output }:
    <span className="ml-5 result-correct-answer-value">{ questions[index].correct }</span>
</span>

3.用户回答

<span className="result-user-answer">
  {parseObject[lang].answer }:
  <span className="ml-5 result-user-answer-value">{userAnswers[index]}</span>
</span>

4.提示信息

<span className={`inline-answer ${ setTypeClassName(index) }`}>
     {
         questions[index].correct === userAnswers[index] 
         ? parseObject[lang].successMsg 
         : parseObject[lang].errorMsg
     }
</span>

5.答案解析

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

<RenderHTMLComponent template={ marked(content) }></RenderHTMLComponent>

这个组件完成之后,实际上,我们的整个项目的大部分就已经完成了,接下来就是一些细节的处理。

以上的源码可以查看此处。让我们看下一个组件的实现。

让我们继续,下一个组件的实现也是最难的,也就是回到顶部效果的实现。

回到顶部按钮组件

回到顶部组件的实现思路其实很简单,就是通过监听滚动事件确定回到顶部按钮的显隐状态,当点击回到顶部按钮的时候,我们需要通过定时器以一定增量来进行计算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 (
      <ButtonComponent
        onClick={onTopHandler.bind(this)}
        className="to-Top-btn btn-no-hover btn-no-active"
        size="mini"
        forwardedRef={ref}
      >
        {props.children ? ( props.children) : (
          <svg
            className="icon"
            viewBox="0 0 1024 1024"
            version="1.1"
            xmlns="http://www.w3.org/2000/svg"
            p-id="4158"
            onMouseEnter={onMouseEnterHandler.bind(this)}
            onMouseLeave={onMouseLeaveHandler.bind(this)}
            ref={svgRef}
          >
            <path
              d="M508.214279 842.84615l34.71157 0c0 0 134.952598-188.651614 134.952598-390.030088 0-201.376427-102.047164-339.759147-118.283963-357.387643-12.227486-13.254885-51.380204-33.038464-51.380204-33.038464s-37.809117 14.878872-51.379181 33.038464C443.247638 113.586988 338.550111 251.439636 338.550111 452.816063c0 201.378473 134.952598 390.030088 134.952598 390.030088L508.214279 842.84615zM457.26591 164.188456l50.948369 0 50.949392 0c9.344832 0 16.916275 7.522324 16.916275 16.966417 0 9.377578-7.688099 16.966417-16.916275 16.966417l-50.949392 0-50.948369 0c-9.344832 0-16.917298-7.556093-16.917298-16.966417C440.347588 171.776272 448.036711 164.188456 457.26591 164.188456zM440.347588 333.852624c0-37.47859 30.387078-67.865667 67.865667-67.865667s67.865667 30.387078 67.865667 67.865667-30.387078 67.865667-67.865667 67.865667S440.347588 371.331213 440.347588 333.852624z"
              p-id="4159"
              fill={props.color}
            ></path>
            <path
              d="M460.214055 859.812567c-1.87265 5.300726-2.90005 11.000542-2.90005 16.966417 0 12.623505 4.606925 24.189935 12.244882 33.103956l21.903869 37.510312c1.325182 8.052396 8.317433 14.216793 16.750499 14.216793 8.135284 0 14.929014-5.732561 16.585747-13.386892l0.398066 0 24.62177-42.117237c5.848195-8.284687 9.29469-18.425651 9.29469-29.325909 0-5.965875-1.027399-11.665691-2.90005-16.966417L460.214055 859.81359z"
              p-id="4160"
              fill={props.color}
            ></path>
            <path
              d="M312.354496 646.604674c-18.358113 3.809769-28.697599 21.439288-23.246447 39.399335l54.610782 179.871647c3.114944 10.304693 10.918677 19.086707 20.529569 24.454972l8.036024-99.843986c1.193175-14.745842 11.432377-29.226648 24.737404-36.517705-16.502859-31.912827-34.381042-71.079872-49.375547-114.721835L312.354496 646.604674z"
              p-id="4161"
              fill={props.color}
            ></path>
            <path
              d="M711.644481 646.604674l-35.290761-7.356548c-14.994506 43.641963-32.889061 82.810031-49.374524 114.721835 13.304004 7.291057 23.544229 21.770839 24.737404 36.517705l8.036024 99.843986c9.609869-5.368264 17.397229-14.150278 20.529569-24.454972L734.890928 686.004009C740.34208 668.043962 730.003618 650.414443 711.644481 646.604674z"
              p-id="4162"
              fill={props.color}
            ></path>
          </svg>
        )}
      </ButtonComponent>
    );
  }
);
const TopComponent = (props) => {
  const btnRef = React.createRef();
  let scrollElement= null;
  let top_value = 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 = 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 (<TopButtonComponent ref={btnRef} {...props} onClick={topHandler.bind(this)}></TopButtonComponent>);
};
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 = 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 (
    <div className="App flex-center">
      <LangContext.Provider value={langValue}>
        <LangComponent lang={langValue} changeLang={changeLangHandler}></LangComponent>
        {
          order > 0 ? order <= 25 ? 
            (
                <div className="flex-center flex-direction-column w-100p">
                  <QuizWrapperComponent 
                      question={ questions[(order - 1 < 0 ? 0 : order - 1)] } 
                      onSelect={ onSelectHandler }
                    >
                    </QuizWrapperComponent>
                  <BottomComponent lang={langValue}>{getCurrentQuestion(langValue, order)}</BottomComponent>
                </div>
            ) 
            : 
            (
              <div className="w-100p result-wrapper" ref={wrapperRef}>
                 <div className="flex-center flex-direction-column result-content">
                    <TitleComponent level={1}>{ getCurrentAnswers(langValue,correctTotal)}</TitleComponent>
                    <ParseComponent lang={langValue} userAnswers={ usersAnswers }></ParseComponent>
                    <RenderHTMLComponent template={marked(parseObject[langValue].endContent)}></RenderHTMLComponent>
                    <div className="button-wrapper mt-10">
                      <ButtonComponent nativeType="button" long onClick={endQuestionHandler}>
                        {parseObject[langValue].endBtn}
                      </ButtonComponent>
                    </div>
                 </div>
                 <TopComponent scrollElement={wrapperRef} color="#ffffff"></TopComponent>
              </div>
            )
            : 
            (
              <div className="flex-center flex-direction-column">
                <TitleComponent level={1}>{parseObject[langValue].title}</TitleComponent>
                <ContentComponent>{parseObject[langValue].startContent}</ContentComponent>
                <div className="button-wrapper mt-10">
                  <ButtonComponent nativeType="button" long onClick={startQuestionHandler}>
                    {parseObject[langValue].startBtn}
                  </ButtonComponent>
                </div>
              </div>
            )
        }
      </LangContext.Provider>
    </div>
  );
}
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则是渲染解析页面。

以上的源码可以查看此处

关于这个网站,我用vue3.X也实现了一遍,感兴趣可以参考源码


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK