Coder Social home page Coder Social logo

san-view's Introduction

san-view

san体系视图层框架

san-view's People

Contributors

otakustay avatar

Stargazers

 avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

san-view's Issues

给通用IoC留出空间

特别是做类Flux结构的时候,如果给IoC留出了空间,那么是有机会证IoC来管理Store并和组件进行整合的,而组件可以这么做:

import {watch} from 'storeUtil';

class Foo extends Componet {
    onAttach(state) {
        // store由IoC提供
        watch(this.store, ('foo', foo => this.setState({foo}));
    }
}

当然还有很多其它的业务场景下,IoC是非常有用的,因此组件体系留出这个空间是很有必要的

Optimistic UI的应用

当前的组件事件处理函数是返回一个Promise<Action>,然后执行这个Action来获取新的状态

对于Optimistic UI,可以让事件处理函数返回[Promise<Action>, Action]的一个tuple,其中后面的那个Action会立即被执行获取新的状态更新界面,而Promise在resolve之后的Action则再次被应用原来的状态获取真正的结果更新界面

从设计上可以得出,当前的组件设计并不妨碍后续Optimistic UI的改造,所以这个事项作为长远规划处理

逻辑流

数据流

在组件树中,数据流自上而下,数据由owner传递给各个关联的组件

数据传递为主动机制,不依赖任何的事件(即不是由子组件监听owner的change事件之类的方式)

Immutable在数据流中的作用参考 #1 ,不再强调

一个组件维护自己和其下面组件的绑定关系,当setState被调用时,会自动通过绑定信息推送数据

pushStateUpdate({previousState, newState}) {
    for (let {target, property, path} of this.bindings) {
        let newValue = visit(newState, path);
        binding.target.setState({[property]: newValue});
    }
}

这个过程不作任何相等的判断,所有的判断都由patchState完成,这导致数据流非常的清晰简单

通知流

通知流在组件树中自下而上

通知使用事件还是回调暂无定论,待讨论

在模板上,对于状态变化的通知有特殊的语法糖,如下:

<san-post on-like-change="onLikeChange"></san-post>

效果为监听�状态变化的通用事件,并从中过滤出like属性的变化调用onLikeChange方法

双向绑定

双向绑定由数据流和通知流组成,如下声明:

<san-post bindx-like="likeCount"></san-post>

等效于:

<san-post bind-like="like" on-like-change="_syncLike"></san-post>
_syncLike(event) {
    this.setState({like: event.newValue});
}

这种模式下双向绑定是一种语法糖,而不是内置的逻辑功能,保持了流的简单清晰(只有数据和通知两条流,不为其他模式开后门)

组件

组件状态

不分state和prop,所有状态可访问性为公开,称为state

组件的状态是不可变的,即每次修改状态都会创建一个新的state

状态更新

对外暴露的方法为patchState(partialState)

另外还有replaceState(newState),用于更新整个状态,且replaceState(newState)还会被用于数据流传递

patchState(partialState) {
    let patch = Object.entries(partialState).reduce(
        (patch, [key, value]) => {
            if (value === this.state[key]) {
                return patch;
            }

            return Object.assign(patch, {[key]: value});
        },
        {}
    );

    if (isEmpty(patch)) {
        return {previousState: this.state, newState: this.state};
    }

    let newState = Object.assign({}, this.state, patch);
    return this.replaceState(newState);
}

replaceState(newState) {
    if (this.state === newState) {
        return {previousState: this.state, newState: this.state};
    }

    let previousState  = this.state;

    this.state = newState;

    let change = {previousState, newState};

    this.pushStateUpdate(change); // 将状态同步到其它组件

    return change;
}

基于Immutable的设计下,组件收到一个新的状态(通过replaceState(newState)调用)时,只要检查引用是否与当前状态相等就行,如果不相等则直接开始进行数据的向下传递,不对状态进行任何深层的判断,这保证了数据流的简单,简单又进一步带动可靠,这是san-view的主要目的之一

组件内部可通过protected的方法notifyStateChange({previousState, newState})对外通知状态的变化,这一方法会触发相关的事件/回调

事件处理

每一个事件处理函数接收当前的状态,并返回一个Promise,该Promise在resolve时提供一个函数(后称为Action),Action接收当前组件的状态并返回状态的补丁(参考上文patchState

同时事件处理函数和Action均接收一个output参数,该参数为一个函数,用于对外发送通知(使用事件或回调的形式,具体由组件控制)

用范式表达为:

async (state, output, ...eventArgs) => (state, output) => newState

一个简单的业务逻辑如下:

async onLike(state, output, event) {
    output('startnetwork');

    let likeCount = await api.updateLikeCount(state.postId);

    output('finishnetwork');

    return (state, output) => {
        output('likeupdate');

        return {like: likeCount};
    };
}

注意事件处理函数得到的状态和Action得到的状态可能是不一样的(因为有异步)

事件处理函数的this为当前组件实例,但非常不建议使用任何带有this访问的代码,尽可能保持事件处理函数是个纯函数

一个事件相当于如下过程:

  1. 调用事件处理函数得到Promise<Action>
  2. 等待Promise进入resolved状态,得到Action
  3. 执行Action,得到state补丁
  4. 调用patchState更新状态
  5. 调用notifyStateChange通知状态变化

需要注意的是通过事件执行的新状态,都会自动通知状态变化,这与外部直接调用patchState不同

注:最初设想一个事件处理函数是直接返回Promise<newState>的,但是考虑到异步会导致状态错乱,所以设计成了Promise<Action>,这会提升一定的复杂性,但更稳健

组件渲染

组件渲染的核心为render(state),接收当前的状态进行处理

组件渲染只会由状态变化触发(暂无其它触发手段),调用patchState后,使用一个所有组件共享的统一调度器进行更新调度

patchState(partialState) {
    // ...前面的代码
    scheduler.scheduleRender(this, change);
}

调度器全局共享一个,通过异步管理,将所有组件的渲染计划收集起来,一个周期后统一处理,一个组件在周期内有多次状态更新,会由调度器进行合并

这会导致在某些时刻状态和界面是不同步的,但这并不重要

在与函数式的配合上,render方法的返回值当前还未决定

组件定义

从上文可以看到,组件中最频繁使用核心功能(状态更新、事件处理)都是纯函数,而不是当前社区中其他框架的依赖this.state等一堆状态

这一特点决定了在san-view体系中,组件事实上是一个“状态的容器”,它本身的逻辑是纯函数,组件实例的存在用来存放状态,而这个存放本身对组件实现者也是透明的

我称这种组件为“纯组件”,与“函数式组件”不同,纯组件的特点是逻辑为纯函数,但本身具备对开发者透明的状态管理,这使得纯组件避开了一般组件状态与生命周期紧耦合难以测试的缺点,也不至于因为实际函数式组件导致数据流过于繁琐

其一个可见的特点为可以针对事件进行测试,可以参考一个简单的 普通组件纯组件 的对比

对于特殊情况,如果一个事件处理函数不能是纯函数,需要用@sideeffect标记,这个标记本身没啥用,但配合一系列工具会有特殊的效果(待定)

复杂应用架构

Flux模型

配合Flux架构,再提供诸如san-flux之类的框架做整合

基本思路依旧是单Store + reducer(mutation)的方式

容器组件与视觉组件

类似Redux的架构中,一个很大的问题是没有提供将“负责界面和交互的组件”和“负责数据传递的组件”拆分开来的指导,这导致在部分质量不高的应用中,一个组件呈现出“部分属性与owner关联,部分属性与store关联,部分事件回调至owner,部分事件通过action送至store”这样的情况,这让逻辑流变得相对复杂

在san-flux中,通过一些官方性的指导,让开发者更倾向于控制一个组件只做一件事:

  1. 如果组件负责界面和交互,那么它的数据来自于owner,事件回调至owner,称为展示组件
  2. 如果组件与store连接,那么它只是store与其它组件的代理桥,负责数据和事件的传递,整合业务逻辑,称为容器组件

这样做可以得到的好处有:

  1. 尽可能地让视觉组件只负责视觉展现和用户交互,本身与业务上的处理逻辑无关,而容器组件则集中处理业务,与store连接能获得更多的地上下文�实现相对复杂的业务逻辑
  2. 每个组件明确只有一个数据来源,一个回调目标,且数据来源和回调目标始终是同一个对象。对应展示组件这个对象是它的owner组件,对应容器组件这个对象是store
  3. 同时在组件树中出现一个明确标记的容器,也更易在界面上表现出明确的区域划分(有点类似iframe的感觉),各区域由于容器组件的存在表现得相互独立,有任何修改影响面也可以确知

容器组件的事件处理函数与展示组件不同,最后的Action返回为空(null吧),即表示不直接产生新的状态,而是去对store进行操作(通过reducer/mutation之类的),再由store通知变化过来进行状态更新

后处理数据的生命周期环节

组件是对一个state的处理函数,而模板的语法限制必然会导致state不能仅包含最小的状态,通常会有一些额外计算的状态要包含其中

那么这些额外计算的状态需要在整个状态更新的流程中有一个明确的点来做,比如这样:

class Component {
    onReceiveState(newState, previousState) {
        return newState;
    }
}

具体组件可以通过重写这个方法,返回一个新的状态来添加一些属性,比如:

class Greeting extends Compnent {
    onReceiveState(newState, previousState) {
        return {
            ...newState,
            message: `Hello ${newState.name}`,
            diff: computeStringDiff(previousState.name, newState.name)
        };
    }
}

框架不负责追踪哪些属性是原始的状态,哪些是由onReceiveState计算得出的,因此在整个组件的生命周期中,更多的时候state里始终有计算出的属性,当状态更新而onReceiveState未处理时,其中这些计算出的属性的值有可能是错误的,这就要求onReceiveState的实现要足够小心,避免把这种错误的值继续传递下去,一个合理的方法是所有的计算而得的属性都重新计算

使用事件还是callback

对于在UI树中自下而上的通知,使用事件还是callback是一个重点考虑的点,下面简单说下各自的优势,优势等于另一个的劣势

比较

callback

  1. 无状态,如果要纯函数那肯定是callback
  2. 在有实例前就可以传递,整个逻辑和实例无关,纯粹用对象描述结构就行,通知是结构的一部分
  3. callstack更清晰

事件

  1. 可多播,对于要注册多个处理函数的场景更方便(callback需要做高阶处理)
  2. 有标准化的preventDefault等接口

关于多播

多播经常发生在以下2个场景下:

  1. 外部的工具介入,如监控等需要注册事件
  2. 行为包装式的组件,如<san-draggable><san-foo on-mouse-down="bar" /></san-draggable>,draggable本身也需要内部组件的mousedown事件

其它选择

混合式

对于业务的逻辑流,使用callback的形式(主线),对于其它场景,同时提供一个多播的事件模型

这样混用可以拆开主业务逻辑和其它框架逻辑,看上去很美好,但不确定会不会反而更混乱了

工具支持

基本工具

由于 #3 初步采取单store的flux架构,因此可以参考现有redux dev tool实现类似功能,基本包含:

  1. 展现出组件树(因为DOM树上看不到明确的组件信息)
  2. 展示store中的状态
  3. 可选的时光机功能(一直觉得作用不是很大)

纯组件检测

基于命令行的工具,检测一个组件是不是定义中的“纯组件”,即检测各种事件处理函数是否为纯函数,对于非纯组件进行报告,并一定程度上指导开发者进行修改

组件逻辑流可视化

在设想中,san-flux应该有一个工具,可以在选择组件树中的一个组件后,看到这个组件上的数据和通知的流入流出情况

在图形上表现为中间一个节点,在这个节点上:

  1. 有多条流入的数据线,每个线都表示一个属性的绑定关系,线连接着来源,来源有明确的分类(store、owner、其它自定义的),同时能标明绑定关系
  2. 有多条流出的数据线,每个线表示它和下层组件的绑定关系
  3. 有多条流出的通知线,每个线表示一个通知(事件/回�调)的去向,线同样连接着来源(store、owner、其它自定义)

这样的一个图能看到一个组件上的逻辑流,如果一个组件连接着多个来源则可以看到逻辑流比较复杂,基于此去思考设计的优化会很有帮助

render的设计

如果需要有Server Render等功能,同时让组件更接近纯组件的概念,那么render方法就必须有返回值,而不是直接基于template去进行渲染

如果render有返回值,那么必定是一个虚拟DOM的节点,这个东西应该称为Element,那么对应方法签名比较合适的应该是:

{Element} render({Object} state, {Function} createElement)

具体代码就可能是这样的:

class Foo extends Component {
    render(state, createElement) {
        return createElement(
            'div',
            {
                attributes: {className: ['ui-foo'].concat(state.customClass)}
            },
            [
                {
                    Bar, // Custom component
                    {
                        state: {value: 123}
                    }
                }
            ]
        );
    }
}

当使用template的时候,就是编译成这个结构

支持Optimistic UI

在后续版本时,框架可以支持Optimistic UI,基本方法如下:

每个事件处理函数返回一个[Promise<Action>, Action],前者负责真正更新状态,后者负责产生Optimistic State。

每次应用Optimistic State的时候,记录以下内容:

  1. 标记当前State是Optimistic的
  2. 产生这个Optimistic State的Action
  3. 与这个Optimistic State相关的Promise
  4. 最后一个正式的State

比如有以下顺序:

  1. 初始状态state0,此时队列为[state0]

  2. 开始事件1,产生[Promise<Action11>, Action12]

  3. 应用Action12,此时队列为:

    [
        state0,
        {state1: Action12(state0), Promise<Action11>, Action12, pending: true}
    ]
    

    使用state1更新UI

  4. 开始事件2,产生[Promise<Action21>, Action22]

  5. 取最后的Optimistic State即state1应用Action22,此时队列为:

    [
        state0,
        {state1: Action12(state0), Promise<Action11>, Action12, pending: true},
        {state2: Action22(state1), Promise<Action21>, Action22, pending: true}
    ]
    

    使用state2更新UI

  6. 假设事件2比事件1更早结束,则获取到Action21,使用在队列中Action21所在项之前的最后的Optimistic State即state1并应用Action21,但同时还要保留Action21(因为前面还有未完成的事件,这个状态并不是真正的最终状态),此时队列为:

    [
        state0,
        {state1: Action12(state0), Promise<Action11>, Action12, pending: true},
        {state3: Action21(state1), Promise<Action21>, Action22, pending: false} // 这里变成false了
    ]
    

    使用state3更新UI

  7. 事件1结束,获取到Action11,使用队列中Action11所在项之前最后的Optimistic State,此时没有这样的状态,因此不做任何操作,把Action11相关的项的pending票房为false

    [
        state0
        {state1: Action12(state0), Promise<Action11>, Action12, pending: false}, // 这里也变成false了
        {state3: Action21(state1), Promise<Action21>, Action22, pending: false} // 这里变成false了
    ]
    
  8. 当有第7步(找不到最近的Optimistic State)时,在队列中从头到尾找所有pending为false的,一个一个应用到正式的State上,直到碰到pending为true的停下来,则队列会变成:

    [
        state4: Action21(Action11(state0))
    ]
    

    将state4作为正式State并更新UI


从框架层面上,只要支持判断事件处理函数返回的是Promise还是数组就行,因此看上去不会产生兼容性破坏

有模板的组件如何处理

有模板的组件特指内部内容由外部决定的那些,模板分为一个默认模板和多个命名模板,比如

<san-toggle-panel>
    <??? for="head">
        Hello
    </???>
    <???>
        <san-foo />
        <san-bar />
    </???>
</san-toggle-panel>

需要明确的是:

  1. 这个<???>到底应该是什么,用<template>/<script>这种允许字符串的东西,还是干脆用<slot>这种表意明确且和shadow dom规范相符的东西
  2. 组件是如何处理这些内容的,在TogglePanel内部怎么实现对这些模板的引用和渲染
  3. 组件要使用这些东西时,比如TogglePanel要head部分的点击事件要如何处理(在不额外再包一层的情况下)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.