after combining the actual needs of the community and referring to various excellent open source projects in the community, the current project directory structure is as follows (for the sake of simplicity, various Test / CI / Linter / Deps files/directories have been removed):
├── components // pure components
│ ├── ArticleActionsPanel
│ ├── ArticleEditFooter
│ ├── ArticleEditToolbar
| ..... skip ....
│ ├── DotDivider
├── containers // smart components
│ ├── AccountEditor
│ ├── AccountViewer
| .... skip ....
│ ├── CheatsheetThread
│ ├── Comments
│ ├── WikiThread
│ └── schemas // common schema/fragment
├── deploy // docker related
│ ├── dev
│ └── production
├── pages // app route files
│ ├── _app.js
│ ├── _document.js
│ ├── _error.js
| .... skip ....
│ ├── api.js
│ ├── discovery.js
├── server.js // custom node server
├── stores // comon/top-level state stores
│ ├── AccountStore
│ ├── RootStore
│ ├── Model
│ ├── ThemeStore
│ ├── ViewingStore
│ ├── index.js
│ └── init.js
├── utils // utils funcitons, themes, common styles ..
│ ├── analytics.js
│ ├── animations.js
│ ├── themes
| .... skip....
│ └── validator.js
"Pure" components are also called "anemia" components in the community, "dump" components, etc., they are not contain any state, only rely on the state of the external props, the output is consistent when the input is consistent, similar to the pure function in functional programming concept .
take DotDivider component as example, the directory structure is as follows:
.
├── index.js
├── styles
│ └── index.js
└── tests
└── index.test.js
Index.js is the entry file for this component and is responsible for the basic implementation of the functionality (if the structure is complex, other components can still be imported under this directory).
import React from 'react'
import T from 'prop-types'
import { Wrapper } from './styles'
import { buildLog } from '@/utils'
/* eslint-disable-next-line */
const log = buildLog('c:DotDivider:index')
const DotDivider = ({ radius, space, className }) => (
<Wrapper radius={radius} space={space} />
)
DotDivider.propTypes = {
radius: T.string,
space: T.string,
}
DotDivider.defaultProps = {
radius: '5px',
space: '5px',
}
export default DotDivider
Styles/index.js is the style file of the component, which corresponds to the components of the parent directory, such as:
index.js --> styles/index.js SubComponent -> styles/sub_component.js
import styled from 'styled-components'
import { theme } from '@/utils'
export const Wrapper = styled.div`
width: ${({ radius }) => radius};
height: ${({ radius }) => radius};
border-radius: 100%;
background-color: ${theme('thread.articleDigest')};
margin-left: ${({ space }) => space};
margin-right: ${({ space }) => space};
display: block;
`
export const Other = 1
the basic unit test for the component.
Container components, also called "congestion" components, "smart" components, etc, including state management, logic, multi-language, GraphQL schema, style, etc., can be seen as a small eco system.
A simple Container component is structured as follows:
├── Editor.js // sub component
├── index.js // entry for current container
├── logic.js // all the logic belongs to this cotainer
├── schema.js // GraphQL schema
├── store.js // state management
├── styles // styles
│ ├── editor.js
│ ├── index.js
│ └── drawer.js
└── tests // tests
├── index.test.js
└── store.test.js
Index.js In addition to the same presentation/integration capabilities as pure components, the biggest difference is the introduction of state management
and logic
. An example of a simplified version is as follows:
import Header from './Header'
import Editor from './Editor'
// ...
import { Wrapper, ViewerWrapper } from './styles'
import { bond, buildLog } from '@/utils'
import { useInit, changeView, onPublish, cancelPublish } from './logic'
const PostEditorContainer = ({ postEditor: store, attachment }) =>{
useInit(store)
const { copyRight, thread, curView, // ... } = postEditor
return (
<Wrapper>
<Header
isEdit={isEdit}
curView={curView}
thread={thread}
referUsers={referUsersData}
/>
<ArticleEditFooter
isEdit={isEdit}
statusMsg={statusMsg}
onCancel={cancelPublish}
onPublish={onPublish}
/>
</Wrapper>
)
}
export default bond(PostEditorContainer)
Based on my own experience and the actual situation of the project's evolution over the past year, I think the local state is bad. So all the states are handed to the external state management tool Mobx-State-Tree, and then the container is linked to the entire project state tree by the following function. Corresponding substate trees are linked together
export default bond(PostEditorContainer)
Store.js is similar to the M layer under the MVC architecture, based on mobx-state-tree, taking into account strong typing and readability, a simple Examples are as follows
...
import { types as T, getParent } from 'mobx-state-tree'
import { Post, Mention } from '@/model'
import { markStates, buildLog, stripMobx, changeset } from '@/utils'
/* eslint-disable-next-line */
const log = buildLog('S:PostEditorf')
const PostEditor = T
.model('PostEditor', {
editPost: T.optional(Post, {}),
mentionList: T.optional(T.array(Mention), []),
// current "@user" in valid array format
referUsers: T.optional(T.array(Mention), []),
publishing: T.optional(T.boolean, false),
isEdit: T.optional(T.boolean, false),
})
.views(self => ({
get root() {
return getParent(self)
},
get curCommunity() {
return stripMobx(self.root.viewing.community)
},
get mentionListData() {
return stripMobx(self.mentionList)
},
...
}))
.actions(self => ({
validator(type) {
...
},
updateEditing(sobj) {
const editPost = merge(self.editData, { ...sobj })
return self.mark({ editPost })
},
reset() {
self.mark({ isEdit: false, mentionList: [] })
self.editPost = { title: '', body: '' }
...
},
mark(sobj) {
markStates(sobj, self)
},
}))
export default PostEditor
All the states here are only used by components in this container directory, belonging to a subtree on the entire application state tree. If you need to access the "main tree" or other "branch" state tree, you can use the get root
method. See MST Document for details. The state cannot be directly updated by the view layer and must be updated via the logic layer by calling store.mark
or other action
methods provided on the store.
Although it is OK, I think the logic does not belong to the view
layer, and the view
layer should have no "side effects". So I put all the logic, whether synchronous or asynchronous, as a convention into the logic.js in the same directory. A simplified version of the logic.js file is as follows
import { merge } from 'ramda'
import { SR71, asyncRes, asyncErr, $solver } from '@/utils'
import { S, updatablePostFields } from './schema'
const sr71$ = new SR71()
/* eslint-disable-next-line */
const log = buildLog('L:PostEditor')
let store = null
let sub$ = null
export const onPublish = () => {
if (!store.validator('general')) return false
sr71$.mutate(S.createPost, variables)
}
// ###############################
// Data & Error handlers
// ###############################
const DataSolver = [
{
match: asyncRes('createPost'),
action: () => {
doneCleanUp()
send(EVENT.REFRESH_POSTS)
},
},
]
const ErrSolver = [
{
match: asyncErr(ERR.GRAPHQL),
action: ({ details }) => cancelLoading(),
},
{
match: asyncErr(ERR.NETWORK),
action: ({ details }) => cancelLoading(),
},
]
export const init = (_store) => {
store = _store
if (sub$) return false
sub$ = sr71$.data().subscribe($solver(DataSolver, ErrSolver))
}
export const uninit = () => {
sr71$.stop()
sub$.unsubscribe()
sub$ = null
}
All logic files have some boilerplate code (can be generated automatically by make gen) . Thanks to the powerful and elegant power of Rx.js and the concept of pattern matching in functional programming, under this architecture, whether the logic is asynchronous or synchronous, it can be handled uniformly by the match-action structure. The entire logic processing flow can be simplified to
Process data
--> Update status tree
and / or Response data
--> Update status tree
The schema definition for all GraphQL requests is here, and the same fragment is extracted to the 'schemas' of the upper directory. The sys layer can make asynchronous requests using sr71$.query(S.post, {...})
import gql from 'graphql-tag'
import { F, P } from '@/schemas'
const post = gql`
query post($id: ID!, $userHasLogin: Boolean!) {
post(id: $id) {
${F.article}
body
commentsParticipants {
${F.author}
}
commentsCount
viewerHasViewed @include(if: $userHasLogin)
viewerHasCollected @include(if: $userHasLogin)
viewerHasUpvoted @include(if: $userHasLogin)
favoritedCategoryId @include(if: $userHasLogin)
}
}
`
const setTag = gql`
${P.setTag}
`
.....
const schema = {
post,
setTag,
.....
}
export default schema