fex-team / fit

百度 React 组件库
http://fit.baidu.com
351 stars 50 forks source link

贴吧 React 最佳实践 #1

Open ascoders opened 8 years ago

ascoders commented 8 years ago

前端是个比较苦逼的工种,面临着一年一变的开发框架,一季一变的脚手架,一月一变工具库,这几年现已经发展到整个开发生态圈一年一变。

然而对于新技术的追求是一定要有的,毕竟唯一不变的东西就是变化,在互联网行业跟不上变化就等于淘汰。对于比较有开发经验的前端同学们来说,学习一项新的框架是非常轻松的,积极订阅技术周刊、看文档、逛github都可以使你迅速跟上前端变化的节奏。

回到现实,在大公司的大业务线,比如我所负责的百度贴吧,情况没有那么乐观。一个十多年的业务线所积累的业务代码是每一个个体无法想象,也无法掌控的,贴吧的前端代码几乎反应了整个前端历史的发展轨迹:在体系复杂的基础项目、林林种种的创新项目、变化多样的运营项目中,几乎所有博文中介绍过的优雅,神奇,黑科技的方法毫无例外都被使用过,框架集中在了jquery生态,是jquery时代混合php编程的经典范例。然而随着前端的发展,产品迭代的加速,旧的前端开发架构已经越来越无力。

在前后端分离开发方式早就被实践的今天,想在贴吧做一点点改变也会受到编译脚本、模块耦合,php全环境问题的困扰,任何小小的优化都会牵一发而动全身,于是我们开始了漫长的改造,从制作新的编译脚本,使用新开发流程,对fis通用化定制,以及后端UI层改为nodejs全方位辅助前端模块化开发,框架选用了React。

写到这里,应该总结一些为什么要使用React理由,毕竟前端变化那么快,为什么这么看好React呢?React不仅仅有非常优秀的模块化机制,普通的业务模块也能拆出来拥抱npm,更重要的是推出了虚拟dom思想,提高dom渲染效率,使得跨平台开发成为可能。也许在未来web app会替代native app(假设),可是虚拟dom更使后端渲染成为了可能,web app也需要借助虚拟dom的优势优化首屏用户体验。

Fis3 vs Webpack

fis3是完整的前端构建工具,webpack是前端打包工具,现在fis3也拥有了webpack对npm生态打包的能力,详情参考这篇文章:如何用 fis3 来开发 React?

让 fis3 拥有 webpack 的打包能力,只需要 fis-conf.js 添加如下配置:

// fis3 中预设的是 fis-components,这里不需要,所以先关了。
fis.unhook('components')

// 使用 fis3-hook-node_modules 插件。
fis.hook('node_modules', {
    ignoreDevDependencies: true // 忽略 devDep 文件
})

假设我们将项目分为 clientserver ,可以按需加载前端用到的文件:

fis.set('project.files', [
    // client 按需加载
    '/client/index.html',
    // server 全部加载
    '/server/**'
])

再将前端文件使用 typescript 编译:

fis.match('/client/**.{jsx,tsx}', {
    rExt  : 'js',
    parser: fis.plugin('typescript', {
        module: 1,
        target: 0
    }),
})

如果上线后需要将文件发布到 cdn 域名下,可以动态替换,同时开启压缩等操作:

const production = fis.media('production')
production.match('*.{css,less,scss,sass,js}', {
    domain: 'http://cdn.example.com'
})

// 压缩 css
production.match('*.{css,scss}', {
    optimizer: fis.plugin('clean-css')
})

// 针对以下下文件,开启文件 hash
production.match('*.{ts,tsx,js,jsx,css,scss,png,jpg,jpeg,gif,bmp,eot,svg,ttf,woff,woff2}', {
    useHash: true
})

// png 压缩
production.match('*.png', {
    optimizer: fis.plugin('png-compressor')
})

// 压缩 js 文件
production.match('*.{js,tsx}', {
    optimizer: fis.plugin('uglify-js')
})

生产环境需要压缩前端文件:

const pack = {
    '/client/pkg/bundle.js': [
        '/client/index.tsx',
        '/client/index.tsx:deps'
    ],
    '/client/pkg/bundle.css': [
        '*.scss',
        '*.css'
    ]
}

// 依赖打包
production.match('::package', {
    packager: fis.plugin('deps-pack', pack)
})

这样就将所有 js 依赖文件都打包到 /client/pkg/bundle.jscss 文件都打包到 /client/pkg/bundle.css,同时fis3会自动替换html中的引用。

Yog2 vs express

yog2是基于express封装的nodejs UI层解决方案,文档地址。主要特点使用了app拆分,使得协同开发变得方便。业务项目与node根项目分离,本地开发时,使用fis3的http-push能力提交到node根项目子项目文件夹中,保证不同业务项目的分离。

先安装yog2:

npm install yog2 -g

运行:

yog2 run -e dev

让项目上传到 yog2 根项目中,需要修改 fis-confg.js

production.match('*', {
    charset: 'utf8',
    deploy : [
        fis.plugin('http-push', {
            receiver: 'http://127.0.0.1:8080/yog/upload',
            to      : '/'
        })
    ]
})

支持 bigpipe、quickling,以及后端渲染,默认支持mvc模式,自动路由:/server/api/user.tsdefault export 默认监听 /[project-name]/api/user 这个url。

开发中支持热更新,只要添加 --watch 参数,无需重启 node 就可以更新代码逻辑:

yog2 release --watch --fis3

Fit vs Antd

Fit和Antd类似,是一款基于commonjs规范的React组件库,同时提供了对公司内部业务线的定制组件,不同的是,Fit组件源码使用typescript编写,使得可维护性较强,由FEX团队负责维护(现在还未对外开放)。

除了提供通用的业务组件以外,还提供了同构插件 fit-isomorphic-redux-tools,这个组件提供了基于redux的同构渲染方法支持。

React 后端渲染企业级实践

先从业务角度解析一遍后端渲染准备工作,之后再解析内部原理。

后端模板的准备工作

对纯前端页面来说,后端模板只需要提供基础模板,以及各种 api 接口。为了实现后端渲染,需要根据当前(html5)路由动态添加内容放到模版中去,因此 fit-isomorphic-redux-tools 提供了封装好的 serverRender 函数:

server/action/index.ts

import * as React from 'react'
import routes from '../../client/routes'
import {basename} from '../../client/config'
import rootReducer from '../../client/reducer'
import serverRender from 'fit-isomorphic-redux-tools/lib/server-render'
import * as fs from 'fs'
import * as path from 'path'

// 读取前端 html 文件内容
const htmlText = fs.readFileSync(path.join(__dirname, '../../client/index.html'), 'utf-8')

export default async(req:any, res:any) => {
    serverRender({
        req,
        res,
        routes,
        basename,
        rootReducer,
        htmlText,
        enableServerRender: true
    })
}

server/router.ts

import initService from './service'

export default (router:any)=> {
    router.use(function (req:any, res:any, next:any) {
        /^\/api\//.test(req.path) ? next() : router.action('index')(req, res, next)
    })

    initService(router)
}

server/router.ts 说起,引入了 service(下一节介绍),对非 /api 开头的 url 路径返回 server/action/index.ts 文件中的内容。

server/action/index.ts 这个文件引用了三个 client 目录下文件,分别是 routes 路由定义、basename 此模块的命名空间、rootReducer redux 聚合后的 reducer。读取了 client/index.html 中内容,最后将参数全部传入 serverRender 函数中,通过 enableServerRender 设置是否开启后端渲染。如果开启了后端渲染,访问页面时,会根据当前路由渲染出对应的 html 片段插入到模板文件中返回给客户端。

在后端抽象出统一的 service 接口

server/service/index.ts

import {initService, routerDecorator} from 'fit-isomorphic-redux-tools/lib/service'
export default initService

class Service {
    @routerDecorator('/api/simple-get-function', 'get')
    simpleGet(options:any) {
        return `got get: ${options.name}`
    }

    @routerDecorator('/api/simple-post-function', 'post')
    simplePost(options:any) {
        return `got post: ${options.name}`
    }

    @routerDecorator('/api/current-user', 'get')
    async currentUser(options:any, req:any) {
        return await setTimeout(() => {
            return 'my name is huangziyi'
        }, 1000)
    }
}

new Service()

fit-isomorphic-redux-tools 还提供了两个工具 initService export 出去供 router 绑定路由,routerDecorator 是个装饰器,第一个参数设置 url 地址,第二个参数设置 httpMethod。定义一个 Service 类,每一个成员函数都是对应的后端 api 函数,支持同步和异步方法。最后创建一个 Service 的实例。

当通过 http 请求访问时,同步和异步方法是没有任何区别的,当请求从后端执行时,不会发起新的 http 请求 ,而是直接访问到这个函数,对异步函数进行异步处理,使得与同步函数效果统一。

自此后端模块介绍完毕了,可以对 service 进行自由拆分,例如分成多个文件继承等等。

前端模板文件处理

client/index.html

<!DOCTYPE html>
<html lang="zh-cn">
    <body>
        <div id="react-dom"></div>
    </body>

    <script>
        window.__INITIAL_STATE__ = __serverData('__INITIAL_STATE__');
    </script>
    <script type="text/javascript" src="./static/mod.js"></script>
    <script type="text/javascript" src="./index.tsx"></script>
</html>

引入 mod.js 是为了支持 fis 的模块化寻找(webpack将类似逻辑预制到打包文件中,所以不需要手动引用),index.tsx 是入口文件,需要通过 fis-conf.js 设置其为非模块化(仅入口非模块化),之后都是模块化引用:

fis.match('/client/index.tsx', {
    isMod: false
})

window.__INITIAL_STATE__ = __serverData('__INITIAL_STATE__'); 这段代码存在的意义是,后端渲染开启时,会替换 __serverData('__INITIAL_STATE__') 为后端渲染后的内容,在 redux 初始化时传入 window.__INITIAL_STATE__ 参数,让前端继承了后端渲染后的 store 状态,之后页面完全交给前端接手。

前端入口文件处理

client/index.tsx

import * as React from 'react'
import * as ReactDOM from 'react-dom'
import routerFactory from 'fit-isomorphic-redux-tools/lib/router'
import routes from './routes'
import {basename} from './config'
import reducer from './reducer'
import './index.scss'

const router = routerFactory(routes, basename, reducer)

ReactDOM.render(router, document.getElementById('react-dom'))

fit-isomorphic-redux-tools 提供了方法 routerFactory 返回最终渲染到页面上的 React 组件,第一个参数是路由设置,第二个参数是项目命名空间(字符串,作为路由的第一层路径,区分子项目),第三个参数是 redux 的聚合 reducer。

routes 是非常单一的 react-router 路由定义文件:

client/routes.tsx

import * as React from 'react'
import {Route, IndexRoute} from 'react-router'
import Layout from './routes/layout/index'
import Home from './routes/home/index'
import PageA from './routes/page-a/index'
import PageB from './routes/page-b/index'

export default (
    <Route path="/"
           component={Layout}>
        <IndexRoute component={Home}/>
        <Route path="page-a"
               component={PageA}/>
        <Route path="page-b"
               component={PageB}/>
    </Route>
)

reducer也是基本的 redux 使用方法:

client/reducer.tsx

import {combineReducers} from 'redux'
import {routerReducer} from 'react-router-redux'

// 引用各模块 reducer
import layout from './routes/layout/reducer'

// 聚合各 reducer
// 将路由也加入 reducer
const rootReducer = combineReducers({
    routing: routerReducer,
    layout: layout
})

export default rootReducer

config 文件是定义文件,将静态定义内容存放于此:

client/config.tsx

export const basename:string = '/my-app-prefix'

action、reducer

存放在 stores 文件夹下. actions 可共用,但对于复杂项目,最好按照 state 树结构拆分文件夹,每个文件夹下对应 action.tsxreducer.tsx。将 Redux 数据流与组件完全解耦。

特别对于可能在后端发送的请求,可以使用 fit-isormophic-redux-tools 提供的 fetch 方法:

client/stores/user/action.tsx

import fetch from 'fit-isomorphic-redux-tools/lib/fetch'

export const SIMPLE_GET_FUNCTION = 'SIMPLE_GET_FUNCTION'

export const simpleGet = ()=> {
    return fetch({
        type: SIMPLE_GET_FUNCTION,
        url: '/api/simple-get-function',
        params: {
            name: 'huangziyi'
        },
        method: 'get'
    })
}

然后在前端任何地方执行,它都只是一个普通的请求,如果这个 action 在后端被触发(比如被放置在 componentWillMount生命周期中),还记得 service 中这段代码吗?

@routerDecorator('/api/simple-get-function', 'get')
simpleGet(options:any) {
    return `got get: ${options.name}`
}

会直接调用此方法。第一个参数是 params(get) 与 data(post) 数据的 merge,第二个参数是 req,如果在后端执行此方法,则这个 req 是获取页面模板时的。

组件

使用 connect 将 redux 的 state 注入到组件的 props,还不熟悉的同学可以搜一搜 react-redux 教程。

组件的介绍为什么这么简单?因为有了 fit-isormophic-redux-tools 插件的帮助,组件中抹平了同构请求的差异。再次强调一遍,在任何地方调用 action ,如果这段逻辑在后端被触发,它会自动向 service 取数据。

fit-isomorphic-redux-tools 剖析

核心函数 serverRender 代码片段

// 后端渲染
export default(option:Option)=> {
    // 如果不启动后端渲染,直接返回未加工的模板
    if (!option.enableServerRender) {
        return option.res.status(200).send(renderFullPage(option.htmlText, '', {}))
    }

    match({
        routes: option.routes,
        location: option.req.url,
        basename: option.basename
    }, (error:any, redirectLocation:any, renderProps:any) => {
        if (error) {
            option.res.status(500).send(error.message)
        } else if (redirectLocation) {
            option.res.redirect(302, redirectLocation.pathname + redirectLocation.search)
        } else if (renderProps) {
            const serverRequestHelper = new ServerRequestHelper(service, option.req)

            // 初始化 fetch
            setServerRender(serverRequestHelper.Request as Function)

            // 初始化 redux
            const store = configureStore({}, option.rootReducer)
            const InitialView = React.createElement(Provider, {store: store}, React.createElement(RouterContext, renderProps))
            try {
                // 初次渲染触发所有需要的网络请求
                renderToString(InitialView)

                // 拿到这些请求的action
                const actions = serverRequestHelper.getActions()
                Promise.all(actions.map((action:any)=> {
                    return store.dispatch(action)
                })).then(()=> {
                    const componentHTML = renderToString(InitialView)
                    const initialState = store.getState()
                    // 将初始状态输出到 html
                    option.res.status(200).send(renderFullPage(option.htmlText, componentHTML, initialState))
                })
            } catch (err) {
                console.log('Server Render Error', err)
                yog.log.fatal(err)
                option.res.status(404).send('Server Render Error')
            }
        } else {
            option.res.status(404).send('Not Found')
        }
    })
}

renderFullPage 方法,返回页面模板,可接收参数将后端渲染的内容填入其中,如果不开启后端渲染,无参调用此方法即可。

// 初始化 fetch
setServerRender(serverRequestHelper.Request as Function)

// 初次渲染触发所有需要的网络请求
renderToString(InitialView)

为了抹平前端请求在后端处理的差异,需要触发两次 renderToString 方法,上述代码是第一次。因为 fetch 方法在前后端都会调用,我们将 serverRequestHelper.Request 传入其中,当 action 在后端执行时,不会返回数据,而是将此 action 存放在 Map 对象中,渲染完毕后再将 action 提取出来单独执行:

const actions = serverRequestHelper.getActions()
Promise.all(actions.map((action:any)=> {
    return store.dispatch(action)
}))

因为 react 渲染是同步的(vue2.0 对此做了改进,可谓抓住了 react 的痛点),对异步操作无法处理,因此需要多渲染一次。这时,redux 的 store 中已经有了动态请求所需的数据,我们只需要再次渲染,就可以获取所有完整数据了:

const componentHTML = renderToString(InitialView)
const initialState = store.getState()
// 将初始状态输出到 html
option.res.status(200).send(renderFullPage(option.htmlText, componentHTML, initialState))

核心函数 promise-moddleware 代码片段

export default (store:any) => (next:any) => (action:any) => {
    const {promise, type, ...rest} = action

    // 没有 promise 字段不处理
    if (!promise) return next(action)

    const REQUEST = type + '_REQUEST'
    const SUCCESS = type + '_SUCCESS'
    const FAILURE = type + '_FAILURE'

    if (process.browser) {
        next({type: REQUEST, ...rest})

        return promise.then((req:any) => {
            next({data: req.data, type: SUCCESS, ...rest})
            return true
        }).catch((error:any) => {
            next({error, type: FAILURE, ...rest})
            console.log('FrontEnd PromiseMiddleware Error:', error)
            return false
        })
    } else {
        const result = promise(action.data, action.req)
        if (typeof result.then === 'function') {
            return result.then((data:any) => {
                next({data: data, type: SUCCESS, ...rest})
                return true
            }).catch((error:any) => {
                next({error, type: FAILURE, ...rest})
                console.log('ServerEnd PromiseMiddleware Error:', error)
                return false
            })
        } else {
            return next({type: SUCCESS, data: result, ...rest})
        }
    }
}

篇幅原因,默认大家了解 redux 中间件的工作原理。这里有个约定,action 所有异步请求都放在 promise 字段上,dispatch 分为三个状态 (_REQUEST,_SUCCESS,_FAILURE)。前端请求都是异步的,因此使用 promise.then 统一处理,后端请求因为直接访问 model ,异步时,与前端同样处理,同步时,直接调用 promise 函数获取结果。还记得 server/service/index.ts 文件中为何能支持普通方法,与 async 方法吗?因为这里分开处理了。

核心函数 service 代码片段

const services = new Map()
export default services

export const routerDecorator = (url:string, method:string) =>(target:any, key:string, descriptor:any)=> {
    services.set(url, {
        value: descriptor.value,
        method: method
    })
    return descriptor
}

export const initService = (router:any)=> {
    for (let key of services.keys()) {
        const target = services.get(key)
        router[target.method](key, async(req:any, res:any)=> {
            let params:any = {}
            if (target.method === 'get') {
                params = req.query
            } else {
                params =  _.assign(req.body || {}, req.query || {})
            }
            const result = await target.value(params, req)
            res.json(result)
        })
    }
}

这里有两个函数,将 service 层抽象出来。routerDecorator 装饰器用于定义函数的路由信息,initService 将 service 信息初始化到路由中,如果是 GET 请求,将 query 参数注入到 service 中,其它请求会对 query 与 body 参数做 merge 后再传给 service。

总结

React 组件生态降低了团队维护成本,提高开发效率,同时督促我们开发时模块解耦,配合 redux 将数据层与模版层分离,拓展了仅支持 view 层的 React。后端渲染大大提高了首屏效率,大家可以自己规划后端渲染架构,也可以直接使用 fit-isormophic-redux-tools

目前来看,React 后端渲染的短板在于 RenderToString 是同步的,必须依赖两次渲染才能方便获取异步数据(也可以放在静态变量中实现一次渲染),对于两层以上的异步依赖关系处理起来更加复杂,这需要 React 自身后续继续优化。当然,任何技术都是为了满足项目需求为前提,简单的异步数据获取已经可以满足大部分业务需求。

webpack只是个打包工具,我们不要过分放大它的优势,一个成熟的业务线需要 gulp 或者 fis3 这种重量级构建工具完成一系列的流程,如今 fis3 已经支持 npm 生态,正在不断改造与进步。对 express 熟悉的同学,转到企业开发时不妨考虑一下 yog2,提供了一套完整的企业开发流程。

如有遗误,感谢指正。

线上项目

贴吧一起嗨,由 FEX 团队助力打造,下面提供了开启后端渲染/关闭后端渲染的链接地址,方便大家调试比对性能。

http://tieba.baidu.com/n/tbhighh5/album/456900832689737361 ,开启后端渲染 http://tieba.baidu.com/n/tbhighh5/album/456900832689737361?nsr=1 ,关闭后端渲染

techird commented 8 years ago

Mark! 满满干货。

GuoYongfeng commented 8 years ago

赞,前排留坐....

jetzhliu commented 8 years ago

感觉如果不想调用两次RenderToString,可以参考react-redux-universal-hot-example,里面使用到了redux-async-connect来触发所有需要的网络请求。

当然,局限性是有的。因为这个做法是依据match返回的components上面的reduxAsyncConnect来找到需要执行的异步请求,故而无需调用RenderToString来触发网络请求,却增加了以下局限性:

  1. 必须把首次加载的所有网络请求都按照协定写到固定的地方(asyncConnect部分,位于component的decorator);
  2. 只有在<Route />定义的component才可以定义初始网络请求。

不过,我觉得这两个限制都是有意义的,而且对大部分情况都适用。唯一不好的地方是若有历史代码,须根据第一点修改。但使用在新项目中就很爽了。

当然,React貌似也有计划增加异步渲染的逻辑,那以后做起来就更方便了。

ps: 社区也有很多异步的renderToString的实现,例如react-async,不足是并非官方提供,并且需要稍微改变定义component的方式(一般采用decorator的方式)

ascoders commented 8 years ago

感谢 @jetzhliu 提供 React 未来会增加异步渲染的重要计划。

另外 reduxAsyncConnect 的思路与将请求放置到 static 变量中无异,本人觉得有第三种局限性:无法获取组件实例,这直接导致了后端请求的参数可能无法与前端代码无缝对接。

比如组件某个实例的 state 就无法被访问到。而渲染两次最大好处在于后端请求部分不需要拆出来,发送请求的参数能取自每个组件实例内部的变量。

再次期待 React 异步渲染的官方支持,非常赞!

ouvens commented 8 years ago

有简单但完整demo没?

ascoders commented 8 years ago

@ouvens 待我整理一下,这周末前会发出来

codering commented 8 years ago

能否发下前端目录结构并说明下各目录的代码规范?

ystarlongzi commented 8 years ago

期待 demo

ystarlongzi commented 8 years ago

最好按照 state 树结构拆分文件夹,每个文件夹下对应 action.tsxstore.tsx

这里的 store.tsx 是干什么用的?

ascoders commented 8 years ago

@ystarlongzi 笔误,是 reducer.tsx ,已修正。

ascoders commented 8 years ago

@codering 今天把 demo 整理出来后,再写一篇目录规范描述

ascoders commented 8 years ago

@ouvens @ystarlongzi demo传送门: https://github.com/ascoders/isomorphic-react-redux-app

@codering 下面是项目启动介绍,目录结构在项目里,感谢支持。

Environment

Start Node Service

In node-server directory, run the following commands:

cnpm install
npm start

Start App Building

In my-app directory, run the following commands:

cnpm install
npm start

This command is executed with Webpack, and you can create other app's folder such like second-app sell-system...

Then visit http://localhost:8080/my-app

Warning: You might see the TS error, we're trying to fix it, it does not prevent development

Start App Preview Production Building

In my-app directory, run the following commands:

npm run preview

This command is executed with Fis3, use production setting

Sandbox Development Building

npm run remote

In my-app directory, change host in fis-conf.js, your code will be pushed to remote machine

Production Building

In my-app directory, run the following commands:

npm run production

Then you can find static-my-app.tar my-app.tar in output dictionary

ystarlongzi commented 8 years ago

@ascoders

辛苦啦!

ystarlongzi commented 8 years ago

@ascoders

对了,文件后缀名 .tsx 有什么寓意嘛?

ascoders commented 8 years ago

对了,文件后缀名 .tsx 有什么寓意嘛?

tsx = typescript + jsx,在大型项目中不能保证大家技术栈是统一的,通过标准的文件名可以做到区分,同时明确的后缀名可以让编译工具更明确,IDE识别得更智能!

shibiaoz commented 8 years ago

牛逼~点赞,构建、编译脚本啥时候开源呐

ascoders commented 8 years ago

感谢标叔捧场了,献上构建脚本传送门:https://github.com/fit-component/isomorphic-build

hkongm commented 8 years ago

mark

benjycui commented 8 years ago

要 mark 的同学,直接点右边的 subscribe

shineSnow commented 7 years ago

还是很期待的