百度贴吧 React.js 最佳实践

Posted 21CTO

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了百度贴吧 React.js 最佳实践相关的知识,希望对你有一定的参考价值。

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

然而对于新技术的追求是一定要有的,毕竟唯一不变的东西就是变化,在互联网行业跟不上变化就等于淘汰。对于比较有开发经验的前端同学们来说,学习一项新的框架是非常轻松的,积极订阅技术周刊、看文档、逛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 文件})


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

 
   
   
 

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'})// 压缩 cssproduction.match('*.{css,scss}', {    optimizer: fis.plugin('clean-css') })// 针对以下下文件,开启文件 hashproduction.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.js,css 文件都打包到/client/pkg/bundle.css,同时fis3会自动替换html中的引用。

Yog2 vs express

先安装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.ts 的 default 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 initServiceclass 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()


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

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

前端模板文件处理

client/index.html

 
   
   
 

<!DOCTYPE html> <html>
    <body>
        <div id="react-dom"></div>
    </body>
   <script>
       window.__INITIAL_STATE__ = __serverData('__INITIAL_STATE__');
   </script>
   <script type="text/javascript" ></script>    <script type="text/javascript" ></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'// 引用各模块 reducerimport layout from './routes/layout/reducer'// 聚合各 reducer// 将路由也加入 reducerconst 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.tsx 与 reducer.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 方法,返回页面模板,可接收参数将后端渲染的内容填入其中,如果不开启后端渲染,无参调用此方法即可。

// 初始化 fetchsetServerRender(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()// 将初始状态输出到 htmloption.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 servicesexport 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,提供了一套完整的企业开发流程。

如有遗误,感谢指正。

来源:https://github.com/fex-team/fit/issues/1


 关于21CTO平台 

21CTO是中国互联网第一技术学习与服务平台。为CTO、技术总监,技术专家,架构师、技术经理,研发工程师等提供学习成长,工作机会、个人增值等高价值服务。

欢迎您注册为21CTO会员,成为专栏作者,有机会加入专家讲师与技术顾问团队等。一起学习成长,一起用技术改变世界。


以上是关于百度贴吧 React.js 最佳实践的主要内容,如果未能解决你的问题,请参考以下文章

React.js 2016 最佳实践

奇舞周刊 145 期—— React.js 2016 最佳实践

展望2016,REACT.JS 最佳实践 | TW洞见

更新片段参数的最佳实践?

在片段和活动之间进行通信 - 最佳实践

android片段-数据传递-最佳实践[重复]