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