全部文章

LINE漫画:通过Page Stack实现流畅的页面切换

2017-10-21 @sunderls

js react transition

本文翻译自 LINE Engineer Blog 日文原文

写在开始

大家好,我是再LINE漫画做JavaScript开发的@sunderls

在LINE中可以直接看漫画了,大家注意到了吗?

点击「···」> 「LINEマンガ」之后,就可以流畅的看免费漫画了。

这里的画面,实际上用的是web技术。画面切换的时候,个人觉得和原生app一样的流畅,大家觉得是这样的吗? 这里是如何做到的,接下来简单的介绍一下。

Web页面的问题

想必大家平时都使用React或者Vue进行前端开发。

也许会觉得Router中加入Transition的话不就能实现上述效果了么。

确实,简单的组合的话确实会顺利显示页面切换动画,但是如果要让动画流畅,就会有以下一些课题:

「后退」按钮点击时的延迟

这是因为Router默认是对dom进行替换操作。LINE漫画的首页很长而且复杂,所以后退到首页的话会非常容易感觉到延迟。但是由于这是运行在LINE内部的应用,所以必须尽量让用户感觉不到违和感。

后退后没有恢复到上次的滚动位置

SPA应用中经常遇到这种问题。虽然可以通过JavaScript暂存滚动位置进行恢复,但是这种实现并不简单,很容易产生页面的上下跳动等不流畅的问题。另外除了垂直滚动位置之外,滚动图等水平方向上的滚动,以及无限加载等情况也会存在,这样以来JavaScript的逻辑会变得越来越复杂。

懒加载的图片会重新加载

图片在滚动到可显示位置时才予以加载,除此之外用一个placeholder来占位 - 这是一种常见的优化手段。但是在页面后退的时候,图片会重新现实一次,有些违和。

这些问题用一句话总结一下就是「在页面后退的时候,之前页面中因为滚动・点击等用户行为所产生的DOM变化如何重现」

参考IOS的实现

为了看起来像原生app,那么我们最好参考一下原生app的实现。

来看看iOS的UINavigationController:

图片出处: https://developer.apple.com/documentation/uikit/uinavigationcontroller

UINavigationController通过Navigation Stack来管理View controller,对View进行push/pop:

func pushViewController(_ viewController: UIViewController, animated: Bool)
func popViewController(animated: Bool) -> UIViewController?

如果用相同的Stack方法来组装页面的话,页面跳转时将不会发生dom替换,前面提到的问题感觉能够全部解决,所以LINE漫画就直接采用了类似的实现方法,称之为Page Stack。

Page Stack的实现

HTML结构

LINE漫画采用了以下页面结构。PageStack中放置各个页面。 (顺便说一下Modal的也是通过Stack进行管理)

<div id="root">
    <PageStack>  

        <page>   
            <content />
            <mask />
        </page>

        <page>
            <content />
            <mask />
        </page>

        ...
  </PageStack>

  <ModalStack />
</div>

接下来说明下JavasScript的实现(以React和react-router为例,部分代码省略)

PageStack的实现

class Stack extends React.PureComponent {
    constructor(props) {
        super(props);

        this.state = {
            stack: []   // 这里放置各个page element
        };

        // 第一个页面先push进去
        this.state.stack.push(this.getPage(props.location));
    }

    componentStack = [];  // 这里放置各个page component

    // 对route中返回的page element进行包裹,加上transition等效果
    getPage(location) {
        return <Page
            onEnter={this.onEnter}
            onEntering={this.onEntering}
            onEntered={this.onEntered}
            onExit={this.onExit}
            onExiting={this.onExiting}
            onExited={this.onExited}
            >
            {
                React.createElement(this.props.appRoute, { location })
            }
        </Page>
    }

    // 根据location的变化更新Stack
    componentWillReceiveProps(nextProps) {
        // 后退的时候,直接pop最上面的page
        if (nextProps.history.action === 'POP') {
            this.state.stack.pop();
        } else {
            if (nextProps.history.action === 'REPLACE') {
                this.state.stack.pop();
            }
            // 跳转新页面的时候,直接push
            this.state.stack.push(this.getPage(nextProps.location));
        }
    }

    // 如果要支持swipe back,在touchstart事件的capture phase进行处理
    componentDidMount() {
        if (this.props.swipable) {
            this.slideContainer.addEventListener('touchstart', this.onTouchStart, true);
        }
    }

    // 只在屏幕最左端touchstart的时候,才进行swipe back
    onTouchStart = (e) => {
        if (this.touchStartX < 10 && this.state.stack.length > 1) {
            e.preventDefault();
            e.stopPropagation();
            this.slideContainer.addEventListener('touchmove', this.onTouchMove, true);
            this.slideContainer.addEventListener('touchend', this.onTouchEnd, true);
        }
    }

    // 根据手指的运动更新page的translateX和mask的opacity
    onTouchMove = (e) => { ... }
    // 手指离开的时候,判断是需要后退,还是什么都不做
    onTouchEnd = (e) => { ... }

    // 这些hook中更新mask的opacity
    onEnter = () => {...}
    onEntering = () => {...}
    onExit = () => {...}
    onExiting = () => {...}

    // push新页面的时候,触发之前页面的componentDidHide
    onEntered = (component) => {
        this.componentStack.push(component);
        const prevTopComponent = this.componentStack[this.componentStack.length - 2];
        if (prevTopComponent && prevTopComponent.componentDidHide) {
            prevTopComponent.componentDidHide();
        }
    }

    // pop页面的时候,触发下一个页面的componentDidTop
    onExited = (component) => {
        this.componentStack.splice(this.componentStack.indexOf(component), 1);
        const topComponent = this.componentStack[this.componentStack.length - 1];
        if (topComponent && topComponent.componentDidTop) {
            topComponent.componentDidTop();
        }
    }
    render() {
        return <TransitionGroup>
            { this.state.stack }
        </TransitionGroup>;
    }
}

export default withRouter(Stack);

作为wrapper的page

// 首先定义transition
const Slide = ({ children, ...props }) => <CSSTransition classNames={'slide'}
    {...props}>
    { children }
</CSSTransition>;

export default class Page extends React.Component {
    constructor(props) {
        super(props);
    }

    // 通过context传递refPage
    getChildContext() {
        return {
            refPage: (c) => {
                this.page = c;
            }
        }
    }

    componentDidEnter = () => {
        if (this.props.onEntered) {
            this.props.onEntered(this);
        }
        if (this.page && this.page.componentDidEnter) {
            this.page.componentDidEnter();
        }
    }

    // 其他的hook也进行相同处理
    componentDidExit = () => {...}
    componentDidTop = () => {...}
    componentDidHide = () => {...}

    render() {
        const props = this.props;
        return <Slide
            {...props}
            onEntered={this.componentDidEnter}
            onExited={this.componentDidExit}
            >
                { props.children }
        </Slide>;
    }
}

Page.childContextTypes = {
    refPage: PropTypes.func
}

作为helper的withStack

class Wrapper extends React.Component {
    constructor(props) {
        super(props);
    }

    render() {
        return React.createElement(this.props.component, Object.assign({},
            this.props,
            {
                ref: this.context.refPage
            }
        ));
    }
}

Wrapper.contextTypes = {
    refPage: PropTypes.func
};

// withStack就是把context中的refPage进行接力
export default function withStack(Component) {
    return (props) => {
        return <Wrapper component={Component} {...props}/>;
    };
}

这样基本上一个stack的实现就完成了,最后来看一下使用方法。

使用示例代码

// 示例page A.js
export default withStack(class A extends React.Component {
    constructor(props) {
        super(props);
    }

    // 页面进入完成 transition结束
    componentDidEnter() {...}
    // 页面退出完成 transition结束
    componentDidExit() {...}
   // 页面再一次到最上层
    componentDidTop() {...}
    // 页面从最上层下来
    componentDidHide() {...}

    render() {
        return <div> page A </div>;
    }
});

// appRoute.js
export default function AppRoute(props) {
    return <Switch location={props.location} >
            <Route path="/a" component={A}/>
            <Route path="/b" component={B} />
            <Route path="/" component={Top} />
    </Switch>
}

// app.js
class App extends React.Component {
    constructor(props) {
        super(props);
    }

    render() {
        return <Router>
            <PageStack
                swipable={true}
                appRoute={AppRoute}
            />
        </Router>
    }
}

ReactDom.render(<App/>, document.querySelector('#app'));

实现效果

实现Page Stack后,页面切换变得非常流畅。

慢慢滑动也是一样

写在最后

为了实现流畅的操作体验,LINE漫画做了很多很多的尝试,其中的基础就是Page Stack。

大家觉得怎么样?如果可以的话,可以在LINE中尝试一下LINE漫画看看实际效果。

LINE的工程师一直在做最努力的尝试来给用户提供最好的服务。我们也在招募前端工程师。如果有兴趣的话,欢迎点击以下链接查看(日语):

フロントエンドエンジニア【LINEプラットフォーム】

ブリッジエンジニア(フロントエンド)【LINEプラットフォーム】