Incubator-104-frontend / sherlock

0 stars 0 forks source link

Component 的資料流混雜 #2

Open DerekHung opened 7 years ago

DerekHung commented 7 years ago

React 的核心概念是data-driven component 而在目前WAP的component 在串接資料上並沒有被明確規範 導致資料的處理上相當混亂,也直接導致了在使用者做了某些互動就會容易產生資料不一致的狀況 以下節錄某一支component 並且針對裡面的錯誤切割出來講解

import { connect } from 'react-redux';
import React, { Component } from 'react';
import CSSModules from 'react-css-modules';
import compose from 'src/util/compose';
import css from './index.css';

import ChronicleEditFormEdu from 'src/client/component_profile/chronicle/editformEdu';
import ChronicleEditFormExp from 'src/client/component_profile/chronicle/editformExp';
import ChronicleEditFormHonor from 'src/client/component_profile/chronicle/editformHonor';
import SummaryEditForm from 'src/client/component_profile/summary/editform';
import EndorsePanel from 'src/client/component_profile/endorse/endorsePanel';

import { createEndorse } from 'src/client/actions/endorse';
import { updatePersonalConfig, loadUserConfigByType, loadedUserInfo, loadProfile, checkIdentity, viewAs } from 'src/client/actions/profile';

class Tasks extends Component {
    constructor( props, context ){
        super( props, context );
        this.state = {};
        this.state.defaultFreekey = "";
        this.state.currentCompleteIndex = 0;
        this.state.completeSequence = ['', 'experience', 'education', 'endorse', 'introduction', 'honor'];
        this.state.completeStatus = {
            avatar:0,
            introduction:0,
            education:0,
            experience:0,
            honor:0,
            gallery:0,
            endorse:0,
            activity:0
        };
        this.state.configType = '51a3297a-c173-47e9-8f8b-8d43b479a61e';
        this.state.ignore = [];
        this.state.mainCloseTime = 0;
        this.state.closeTime = 0;

這個component 本身有連結redux store data ,可是自身的state 又自己擁有data去做handle,導致資料的更新跟維護上面變得相當複雜(需要隨時注意資料同步的問題)

    }
    componentDidMount() {
        let params = {};
        params.pid = this.props.user.pid
        params.typeList = this.state.configType;

        this.props.loadUserConfigByType(params).then(function (res) {
            if (res.response && res.response[0]) {
                this.state.ignore = res.response[0].value.ignore;
            }
        }.bind(this));
    }
    componentWillReceiveProps(nextProps) {
        if(nextProps.profile.user_info && nextProps.profile.user_info.completeStatus && nextProps.profile.config[this.state.configType] ) {
            this.state.completeStatus = nextProps.profile.user_info.completeStatus;
            this.state.ignore = nextProps.profile.config[this.state.configType].value.ignore;
            this.state.closeTime = nextProps.profile.config[this.state.configType].value.closeTime;
            this.state.mainCloseTime = nextProps.profile.config[this.state.configType].value.mainCloseTime || 0;

            var date = (new Date()).getTime();
            var mainSkipTime = this.state.mainCloseTime+2592000000;

            if (this.state.mainCloseTime !==0 && date<mainSkipTime) {
                this.state.currentCompleteIndex = -1;
                return;
            }

            for (var index = 1; index <= this.state.completeSequence.length; index++) {
                var seq = this.state.completeSequence[index];
                var status = this.state.completeStatus[seq];
                var skipTime = this.state.closeTime + 2592000000;
                var skip = false;

                if (this.state.ignore.indexOf(index)>=0) {
                    if( date<skipTime) {
                        skip = true;
                    }
                }

                if ((status===null || status === 0) && !skip ) {
                    this.state.currentCompleteIndex = index;
                    break;
                }
            }
        }
    }
  syncUserInfo() {
        let paramsConfig = {};
        let updateData = [];

        updateData = {};
        updateData.pid = this.props.user.pid;
        updateData.type = this.state.configType;
        updateData.value = {mainCloseTime: this.state.mainCloseTime, closeTime: this.state.closeTime, ignore: this.state.ignore.sort()};

        paramsConfig.updateData = JSON.stringify([updateData]);
        paramsConfig.pid = this.props.user.pid;

        this.props.updatePersonalConfig(paramsConfig).then(function(res) {
            if(res.response) {
                let paramsc = {};
                paramsc.pid = this.props.user.pid
                paramsc.typeList = this.state.configType;
                this.props.loadUserConfigByType(paramsc);
                let params = {};
                params.pid = -3;
                params.targetPid = this.props.user.pid;
                this.props.loadedUserInfo(params);
            } else {
                alert('some error');
            }
        }.bind(this));
    }

這邊在componentRecieveProps 用了大量的判斷式去確保資料的同步,但是造成了閱讀上相當困難,也很難確保所有資料有被同步到

    handleExpEdit (e) {
        var date = new Date();
        this.state.closeTime = date.getTime();
        this.state.ignore.push(1);
        this.state.currentCompleteIndex = -1;
        this.syncUserInfo();
    }
    handleEduEdit (e) {
        var date = new Date();
        this.state.closeTime = date.getTime();
        this.state.ignore.push(2);
        this.state.currentCompleteIndex = -1;
        this.syncUserInfo();

    }
    handleHonorEdit(e) {
        var date = new Date();
        this.state.closeTime = date.getTime();
        this.state.ignore.push(5);
        this.state.currentCompleteIndex = -1;
        this.syncUserInfo();
  }
    handleEditIntroduction(e) {
        switch(e) {
            case 'none':
            var date = new Date();
            this.state.closeTime = date.getTime();
            this.state.ignore.push(4);
            this.state.currentCompleteIndex = -1;
            this.syncUserInfo();
            break;
        }
    }
    handleCreateEndorse ({title, desc, type}) {
        this.props.createEndorse({
            targetPid: this.props.user.pid,
            item: title,
            type: type,
            desc: desc,
            pid: this.props.user.pid
        });
        var date = new Date();
        this.state.closeTime = date.getTime();
        this.state.ignore.push(3);
        this.state.currentCompleteIndex = -1;
        this.syncUserInfo();
    }
    skipCreateEndorse () {
        var date = new Date();
        this.state.closeTime = date.getTime();
        this.state.ignore.push(3);
        this.state.currentCompleteIndex = -1;
        this.syncUserInfo();
    }

大量功能重複的函式以及無意義的 flag,沒有註解/ flag map 無從得知這個function 會有什麼結果

    closeAllTask() {
        var date = new Date();
        this.state.mainCloseTime = date.getTime();
        this.state.currentCompleteIndex = -1;
        this.syncUserInfo();
    }
    updateDefaultFreekey(str) {
        this.state.defaultFreekey = str;
        this.setState({ defaultFreekey: this.state.defaultFreekey   });
    }
    render() {
        const renderAry = [];
        this.state.currentCompleteIndex === 1 && renderAry.push(
        <div styleName="task">
            <i className="cross icon" onClick={this.closeAllTask.bind(this)} data-gtm-index="關閉 經歷"></i>
            <div styleName="task_title" className="h2">新增工作經歷</div>
            <div styleName="task_desc">增加多筆工作經歷,讓企業能了解您這樣的頂尖人士!</div>
            <ChronicleEditFormExp
                changeStatus={()=>this.handleExpEdit()}
                editformClass="add"
                params={ {pid: this.props.user.pid } }
                user={ this.props.user }
                cancelButtonText="略過"
                mode="simple" />
        </div>)

        this.state.currentCompleteIndex === 2 && renderAry.push(
        <div styleName="task">
            <i className="cross icon" onClick={this.closeAllTask.bind(this)} data-gtm-index="關閉 學歷"></i>
            <div styleName="task_title" className="h2">新增學歷背景</div>
            <div styleName="task_desc">完成學歷背景,讓你輕易的拓展職場人脈</div>
            <ChronicleEditFormEdu
                changeStatus={()=>this.handleEduEdit()}
                editformClass="add"
                params={ {pid: this.props.user.pid } }
                user={ this.props.user }
                cancelButtonText="略過"
                mode="simple" />
        </div>)

        this.state.currentCompleteIndex === 3 && renderAry.push(
        <div styleName="task">
            <i className="cross icon" onClick={this.closeAllTask.bind(this)} data-gtm-index="關閉 肯定"></i>
            <div styleName="task_title" className="h2">新增專長特質與證照</div>
            <div styleName="task_desc">新增專長、特質或證照可以讓大家來肯定你的專業!</div>
            <EndorsePanel
                add={ true }
                isEdit={ true }
                desc=""
                viewas={ this.props.profile.viewas }
                handleSubmit={ this.handleCreateEndorse.bind(this) }
                handleCancel={ this.skipCreateEndorse.bind(this) }
                params={ {pid: this.props.user.pid } }
                user={ this.props.user }
                cancelButtonText="略過"
                simpleMode
            />
        </div>)

        this.state.currentCompleteIndex === 4 && renderAry.push(
        <div styleName="task">
            <i className="cross icon" onClick={this.closeAllTask.bind(this)} data-gtm-index="關閉 關於我"></i>
            <div styleName="task_title" className="h2">新增關於我</div>
            <div styleName="task_desc">新增個人簡介,清楚傳達個人價值和想法。</div>
            <SummaryEditForm
                changeEditStatus={ this.handleEditIntroduction.bind(this) }
                pid={ this.props.user.pid }
                privacySetting={ 1 }
                defaultFreekey={ ( this.state.defaultFreekey ) ? this.state.defaultFreekey : this.props.profile.user_info.introduction }
                updateDefaultFreekey={ this.updateDefaultFreekey.bind(this) }
                user={ this.props.user }
                cancelButtonText="略過"
                simpleMode
            />
        </div>)

        this.state.currentCompleteIndex === 5 && renderAry.push(
        <div styleName="task">
            <i className="cross icon" onClick={this.closeAllTask.bind(this)} data-gtm-index="關閉 職涯成就"></i>
            <div styleName="task_title" className="h2">新增職涯成就</div>
            <div styleName="task_desc">新增職涯成就,展現專業能力及團隊合作能力!</div>
            <ChronicleEditFormHonor
                changeStatus={()=>this.handleHonorEdit()}
                editformClass="add"
                user={ this.props.user }
                chronicle={ this.props.chronicle }
                cancelButtonText="略過"
                simpleMode
            />
        </div>)

        return renderAry.shift();
    }
}

render 裡面放了大量的markup 跟 邏輯判斷式,但我們很難驗證這些flag 到底數值的意義跟正確性 同時我們也很難得知到底是store裡面的data 有錯還是 state 裡面的data 有錯 再來是大量的結構其實可以共用,但這邊沒有做好拆分

function mapStateToProps( state, props ) {
    return {
        profile: state.profile,
        user: state.user,
        chronicle: state.chronicle,
    }
}

const actions = {updatePersonalConfig, loadUserConfigByType, loadedUserInfo, loadProfile, checkIdentity, viewAs,createEndorse}

export default compose(
    connect(mapStateToProps, actions),
    //translate([]),
    [CSSModules, '_', css, { allowMultiple: true }]
)(Tasks);
DerekHung commented 7 years ago

這邊使用 textfeild (react處理起來相當麻煩的component..)為範例 訂定一個未來重構後的標準component 的實作方式

在新的作法中我們會把react 元件拆分成 Data Layer container 跟 Presentational component Data Layer Container 負責跟Redux store 串接,集中管理資料流與傳遞props Presentational component只負責render markup 以及UI邏輯處理,不能串接redux store 各司其職讓UI跟Data抽離,也讓元件更容易共用跟測試

textfeild.js

import React, { Component, PropTypes } from 'react';
import ReactDOM from 'react-dom';
import CSSModules from 'react-css-modules';
import calculateNodeHeight from './calculateNodeHeight';
import style from './style.css';
class TextFeild extends Component {

    constructor(props) {
        super(props);
        this.state = {
            style: {}
        }
        this.inputNode = null;
    }

    // react lifeCycle method 寫在render 上方,方便總覽整隻程式的生命週期
    componentDidMount() {
        // 當component initial 完成之後 (client render)去抓取node的DOM結構並且計算真實的style
        // 寫進state 裡面 (state裡面只存放跟data 沒有關連的UI邏輯 )
        this.setState({
            style: this._resizeElement(this.inputNode)    
        })
    }   

    componentWillReceiveProps(nextProps) {
        // 當父層的value 更新之後pass value 下來會在這邊接收到更新訊息
        // 這時候重新計算input 的scrollHeight,取得應該有的高度寫進state裡
        if ( nextProps.value !== this.props.value ) {
            this.setState({
                style: this._resizeElement(this.inputNode)    
            })
        }
    }

    // 使用自定義的render function 回傳複雜的條件判斷markup 
    // 盡量避免在function 內產生side effect,保持function 的pure 方便測試
    get renderInput() {
        const { style } = this.state;
        const { type, ...others } = this.props;
        switch(type){
            case 'textarea': 
                return (
                    <textarea 
                        ref={ node => { this.inputNode = node;}}
                        style={style}
                        { ...others } 
                    />
                );
            default:
                return (
                    <input 
                        ref={ node => { inputNode = node;}}
                        { ...others } 
                    />
                );
        }
    }

    render() {
        // 強制props 只能用object spread 方式取值,避免直接使用 this.props.data 的方式
        const { minRows, maxRows,maxWords, value, errorMessage, className, type, ...others} = this.props;

        // 將static markup 用變數存起來,方便整理跟重用 
        const renderMaxWords = maxWords ? (
            <span styleName="maxWord">
                <span styleName="front">{value.length}</span>/{maxWords}
            </span>
        ): null;

        const renderErrorMessage = errorMessage ? (
            <div styleName="errorMessage">{ errorMessage }</div> 
        ) : null;  

        // render return 的JSX語法盡量簡潔,並且用意義清楚的方式命名
        return (
            <div styleName={'input inputRoot'} className={className}>
                { this.renderInput }
                { renderMaxWords }
                { renderErrorMessage }
            </div>
        );
    }

    // 自定義function 寫在render 下方,區隔開lifecycle method
    _resizeElement(inputNode) {
        return calculateNodeHeight(
                inputNode,
                false,
                this.props.minRows,
                this.props.maxRows
            )
    }
}

// 嚴格定義propTypes,必要參數跟optional 參數必須定義清楚
TextFeild.propTypes = {
    name: PropTypes.string.isRequired,
    value: PropTypes.string.isRequired,
    type: PropTypes.string.isRequired,
    disabled: PropTypes.bool,
    placeholder: PropTypes.string,
    errorMessage: PropTypes.string,
    minRows: PropTypes.number,
    maxRows: PropTypes.number,
    maxWords: PropTypes.number,
    onChange: PropTypes.func,
    onBlur: PropTypes.func,
    onFocus: PropTypes.func,
    onKeyDown: PropTypes.func,
}
TextFeild.defaultProps = {
    // type could be number, text, email, textarea... html5 input type
    type: 'text',
    minRows: 2,
    disabled: false
}

export default CSSModules(TextFeild,style,{allowMultiple:true});
DerekHung commented 7 years ago

這邊是一個簡單的Form 表單實例,Data Layer Container層專注在管理資料的同步跟更新議題 更理想的作法是把裡面的markup 全部拆分出去成為Stateless component 但這沒有絕對正確的作法,更多的時候是在眾多優劣中取得平衡,但,一定要經過縝密的討論跟思考 這部份的選擇未來會是團隊緊密討論跟code review 產出共同的實作認知


import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import CSSModules from 'react-css-modules';
import style from './style.css';
import TextField from 'client/components/textfeild';
import { getPersonalData, submitData  } from 'action';

class Form extends Component {

    constructor(props) {
        super(props);
        // container 層接收store的資料之後在生命週期內保存資料
        this.state = {
            formData: props.user
        }
    }
    componentWillMount(){
        // 取得store 裡的personal data 保存進state裡面
        this.props.getPersonalData();
    }
    render(){
        const { name, phone, email } = this.state.formData;

        const renderInput = (value) => {
            return(
                <TextFeild 
                    name={value}
                    value={value}
                    styleName="input"
                    type="text"
                    maxWords={10}
                    onChange={this._inputOnChange}
                />
            )
        }

        return(
            <div>
                <h2>姓名</h2>
                { renderInput(name) }
                <h2>電話</h2>
                { renderInput(phone) }
                <h2>email</h2>
                { renderInput(email) }
                <button onClick={this._handleSubmit} />
            </div>
        )
    }
    _inputOnChange(e) {
        // 接收到子層的popup 事件之後更新container 層的資料 (資料的保存更新、連結store方式全部都在container層)
        const { formData } = this.state;
        formData[e.target.name] = e.target.value;
        this.setState(formData);
    }
    _handleSubmit(e) {
        // button click之後發送submit action
        const { formData } = this.state;
        this.props.submitData(formData);
    }
}

function mapStateToProps(state, props) {
    return {
        user: state.user
    };
}

export default compose(
    connect(mapStateToProps, { getPersonalData, submitData }),
    [CSSModules, '_', css]
)(Form);