Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

贴吧 React 最佳实践 #1

Open
ascoders opened this issue Apr 26, 2016 · 20 comments
Open

贴吧 React 最佳实践 #1

ascoders opened this issue Apr 26, 2016 · 20 comments

Comments

@ascoders
Copy link
Contributor

ascoders commented Apr 26, 2016

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

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

@ascoders ascoders changed the title 百度 React 最佳实践 贴吧 React 最佳实践 May 1, 2016
@techird
Copy link
Member

techird commented May 3, 2016

Mark! 满满干货。

@GuoYongfeng
Copy link

赞,前排留坐....

@jetzhliu
Copy link

jetzhliu commented May 3, 2016

感觉如果不想调用两次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
Copy link
Contributor Author

ascoders commented May 4, 2016

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

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

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

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

@ouvens
Copy link

ouvens commented May 4, 2016

有简单但完整demo没?

@ascoders
Copy link
Contributor Author

ascoders commented May 4, 2016

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

@codering
Copy link

codering commented May 6, 2016

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

@ystarlongzi
Copy link

期待 demo

@ystarlongzi
Copy link

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

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

@ascoders
Copy link
Contributor Author

ascoders commented May 6, 2016

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

@ascoders
Copy link
Contributor Author

ascoders commented May 6, 2016

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

@ascoders
Copy link
Contributor Author

ascoders commented May 7, 2016

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

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

Environment

  • nodejs ^4.1.0
  • cnpm ^3.4.0
  • fis3 ^3.3.21
  • yog2 ^0.6.1

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
Copy link

@ascoders

辛苦啦!

@ystarlongzi
Copy link

@ascoders

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

@ascoders
Copy link
Contributor Author

ascoders commented May 7, 2016

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

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

@shibiaoz
Copy link

shibiaoz commented May 8, 2016

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

@ascoders
Copy link
Contributor Author

ascoders commented May 8, 2016

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

@hkongm
Copy link

hkongm commented May 18, 2016

mark

@benjycui
Copy link

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

@shineSnow
Copy link

还是很期待的

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests