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

243次阅读  |  发布于3年以前

在线示例

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

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

也许有人咋一看,看到这个网站有些熟悉,没错,这个网站来源于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 也实现了一遍,感兴趣可以参考源码。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8