Giter Club home page Giter Club logo

icejs's People

Contributors

alvinhui avatar clarkxia avatar imsobear avatar luhc228 avatar xyeric avatar zhaofinger avatar

Stargazers

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

Watchers

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

icejs's Issues

React 中的状态自动保存(keep-alive)

问题背景

在 React 中通常会使用 react-router 去管理不同的页面切换,通过监听路由的变化匹配到对应的路由组件进行渲染,而未匹配到的组件将会被卸载, 这意味着组件被卸载之后其状态丢失

但在某些场景下,我们希望切换路由时还能保留组件的状态不被丢失,在下次展示时进行恢复,如:

场景一:

当用户在列表页面进入详情页面后,缓存列表页面的状态,当从详情页面返回列表页面时,能恢复到上次的状态(位置状态)。

场景二:

当用户在填写表单但未提交时、可能因为某些原因需要临时离开当前页面,当从其他页面返回表单页面时,能恢复用户填写的表单信息(数据状态)。

keep-alive 简介

什么是 keep-alive?

将组件保存在内存中,而不是卸载以及重新创建,避免重新渲染。也就是所谓的组件缓存。

  • 通过 keep-alive 可以保存当前页面的数据、状态、滚动条位置及渲染内容。当切换到对应页面时,被保存的页面将直接被渲染,还原为切换前的内容。

  • 被 keep-alive 包含的组件不会被再次初始化,也就意味着不会重走生命周期函数,但是有时候是希望我们缓存的组件可以能够再次进行渲染,因此被包含在 keep-alive 中创建的组件,通常会多出两个生命周期的钩子: activateddeactivated

  • activated:当 keep-alive 包含的组件再次渲染的时候触发
  • deactivated:当 keep-alive 包含的组件销毁的时候触发

在 React 中使用 keep-alive ?

在 React 中并没有 keep-alive 的概念,该概念来源于 Vue 提供的 keep-alive 功能。因此也有人在 facebook/react/issues/12039 中询问 React 是否能支持类似 Vue keep-alive 的功能。从 issue 看没有支持的计划, Dan 给出的说法是可以通过类似 style={{display: visible ? 'block' : 'none'}} 的思路,但不认为 keep-alive 是一个好的功能,以及可能会存在内存泄漏等。但强大的 React 社区肯定不会善罢甘休,也有了相关的实现。

在 React 实现 keep-alive

手动保存状态

手动保存状态即通过 React 提供的 componentWillUnmount 生命周期通过 redux 之类的状态管理库对数据进行保存,通过 componentDidMount 生命周期进行数据恢复,这也是目前最常见的解决方式,这里不展开讨论。

通过路由实现状态保存

由于状态丢失的主要原因是由于路由切换时导致组件被卸载,如果是这样,是否只需要保证组件不被卸载,或者在组件卸载之前将数据状态保存就可以解决我们的问题了,事实上目前社区的相关实现也正是这样。

核心思路:路由匹配 -> 组件渲染 -> 切换路由 -> 组件卸载 => 路由匹配 -> 组件渲染 -> 切换路由 -> 组件隐藏

社区方案对比:

库/功能 react-keep-alive react-keeper react-router-cache-route react-activation
实现思路 将 KeepAlive 的组件藏于其 Provider 中,保证其不会被卸载 - 基于 react-router 完全实现一个自定义的路由库 改写 react-router 库的 Route 组件,控制渲染行为,使其不被卸载 同 react-keep-alive
GitHub star 407 708 474 217
NPM download < 300 < 200 ~ 3k < 150
使用方式 KeepLive CacheLink CacheRoute、CacheSwitch KeepAlive
Class 生命周期 componentDidActivate componentWillUnactivate - componentDidCache componentDidRecover componentDidActivate componentWillUnactivate
Function 生命周期 useKeepAliveEffect - - useActivate useUnactivate
恢复滚动位置 支持 支持 支持 支持
缓存控制 支持 - 支持(不友好) 支持
总结 ★★★★ ★★ ★★ ★★★★

主要实现思路:

详见代码

在 icejs 中使用 keep-alive

TODO

小结

keep-alive 作为状态保存的一种实现方式,在某些场景如列表位置恢复,表单状态保存等非常有用,且无需重复渲染组件,是一种很好的选择。但当项目和数据复杂的情况下,需要合理使用 keep-alive 进行状态保存,以及数据的自动清理等。

相关链接:

仓库采用 Lerna Independent 模式

需求

目前仓库使用 lerna fixed 模式,需要改为 Independent 模式进行包版本开发管理,只发布变更的 package

TODO

  • 发布流程支持 Independent 模式下的独立发布

[RFC] [plugin-store] 为模型默认提供 `update` 方法

需求背景

当前模型声明区分了 effects 和 reducers,在 effects 中会经常需要更新模型中的某个字段,例如:

export default {
  state: {
    name: 'alvin',
    age: 22,
  },
  effects: {
    setNameAsync: async function(state, name, actions) {
      await fetch('/...');
      actions.setName(name);
    },
    setAgeAsync: async function(state, age, actions) {
      await fetch('/...');
      actions.setAge(age);
    },
  },
  reducers: {
    setName:(prevState, name) => {
      return {
        ...prevState,
        name,
      };
    },
    setAge(prevState, age) {
      return {
        ...prevState,
        age,
      };
    },
  },
};

这样将造成大量的样板代码。

解法

我们可以将上面的代码进行一些优化,写法可能变成:

export default {
  state: {
    name: 'alvin',
    age: 22,
  },
  effects: {
    setNameAsync: async function(state, name, actions) {
      await fetch('/...');
      actions.update({ name });
    },
    setAgeAsync: async function(state, age, actions) {
      await fetch('/...');
      actions.update({ age });
    },
  },
  reducers: {
    update:(prevState, nextState) => {
      return {
        ...prevState,
        ...nextState,
      };
    },
  },
};

如果我们将 update 方法内置,将提供更大的便利性,例如在视图中:

import { useEffect } from 'react';
import { store } from 'ice';

function Todos() {
  const [, actions] = store.useModel('todos');

  useEffect(() => {
    const data = fetch('//...');
    actions.update(data);
  }, []);
}

Support SSR

使用说明

工程配置

在 build.json 配置 ssr 为 true

{
+ "ssr": true, 
}

应用数据

import { createApp } from 'ice';
createApp({
 app: {
   // 获取初始数据
+   getInitialProps: async () => {
+    return {};
+   }
 }
});

数据请求

在 SSR 应用中,推荐在页面入口组件中定义 getInitialProps 属性,来处理数据请求的工作:

import React from 'react'
import { request } from 'ice'

const Home = ({ stars }) => {
  return <div>{stars}</div>
};

+Home.getInitialProps = async () => {
+  const res = await request('https://api.github.com/repos/ice-lab/icejs')
+  const json = await res.json()
+  return { stars: json.stargazers_count };
+};

Home.pageConfig = {
  // SSR 支持的配置项:详见底部讨论区
+  title: 'Home Page',
}

export default Home;
  • 通过 getInitialProps  返回的数据,将被作为组件的初始 props
  • 在 server 端, getInitalProps  会在页面 render 之前被执行,其返回值将作为页面的初始 props 用于渲染。同时这份数据会被放置于页面中
  • 在 client 端,会优先去检测页面中是否输出了这份数据,如果有,则使用这份数据 hydrate 页面,如果没有,则执行 getInitialProps 
  • 只有放在 pages 目录下的路由组件,它的 getInitialProps  才会被调用,子组件使用 getInitialProps  是无效的

构建产物

.
├── build
│   ├── index.html
│   ├── css/index.css
│   ├── js/index.js
│   └── server.js

实现方案

核心点

  • 一套路由:统一前端路由与服务端路由
  • 数据请求:支持切换路由时自动获取数据
  • HTML 模板:支持配置 HTML 模板

一套路由

客户端和服务端使用一套路由,服务端通过 ctx.path  查找请求对应的组件。封装 getComponentByPath  方法:

import React from 'react'
import { matchPath } from 'react-router-dom'

export interface FC extends React.FC {
  getInitialProps?: (params: any) => Promise<any>
  Layout?: React.FC
}

export interface RouteItem {
  path: string
  exact?: boolean
  Component: () => FC
}

const NotFound: FC = () => {
  return (
    <div>路由查询404</div>
  )
}

const getComponentByPath = (routes: RouteItem[], path: string) => {
  // 根据请求的 path 查找对应的 component
  const matchRoute = routes.find(route => {
    // 找不到对应的组件时返回 NotFound 组件
  	return matchPath(path, route)) || { Component: () => NotFound }
  })

  // Notes:
  //  需要获取当前路由是否存在匹配的 Layout
  return {
      Component:  matchRoute.Component,
      Layout
   }
}

export default getComponentByPath

服务端渲染

根据路径获取到需要渲染的组件以及调用 getInitialProps 获取数据并渲染, 并在服务端编译为** HTML 返回给客户端。

渲染入口

// .ice/server.js
import React, { useState, useEffect } from 'react'
import { renderToString } from 'react-dom/server'
import * as ejs from 'ejs'
import routes from '@/routes'

const serverRender = async (ctx) => {
  // 01. 根据 ctx.path 获取请求的具体组件
  const { Component, Layout } = getComponentByPath(routes, ctx.path)
  
  // 02. 页面数据:判断是否有 getInitialProps 方法,如果有则调用 getInitialProps 方法获取数据
  const pageInitialData = Component.getInitialProps ? await Component.getInitialProps(ctx) : {}
  
  // 03. 将数据作为组件的 props 传入,使得组件可以通过 props.xxx 的方式来读取到服务端获取的数据
  const element = (
     <StaticRouter location={ctx.req.url} context={... pageInitialData}>
         <Layout  {...initialData}>
           <Component {...initialData} />
         </Layout>
    </StaticRouter>
  )

  // 04. 获取组件 HTML 内容
  const elementHtml = renderToString(element);
  
  // 05. 组装 HTML 内容
  const html = ejs.render(templateContent, {
    htmlContent,
    ...matchComponent
  });
  
  return html
}

export default serverRender

HTML 模板

<html <% if(htmlAttrs){ %> <%- htmlAttrs %><% }%>>
  <head >
    <meta charset="utf-8" />
    <meta http-equiv="x-ua-compatible" content="ie=edge,chrome=1" />
    <meta name="viewport" content="width=device-width" />
    <% if(meta){ %> <%- meta %><% }%>
    <title><%- title %></title>
    <% if (link) { %>
    <%- link %>
    <%} else { %>
    <link href="<%- ASSET_PATH %>/css/index.css" rel="stylesheet">
    <% } %>
    <script>
       <!-- 注入数据 -->
       <% if (initialData) { %>
          window.__INITIAL_DATA__ = <%- initialData %>;
          window.__SSR__= true
       <% } %>
    </script>
  </head>

<body <% if(bodyAttrs){ %> <%- bodyAttrs %><% }%>>
  <div id="ice-container"></div>
  <% if (script) { %>
  <%- script %>
  <%} else { %>
  <script type="text/javascript" src="<%- ASSET_PATH %>/js/index.js"></script>
  <% } %>
</body>
</html>

客户端 hydrate

通过获取到服务端直出的 HTML 字符串,能够展现一个有内容的页面,但此时的页面并没有绑定事件和数据,需要通过 React.hydrate 进行处理。

// build-plugin-ice-ssr/module.ts
import * as React from 'react';
import * as ReactDOM from 'react-dom';

const module = ({ modifyDOMRender, wrapperRouteComponent }) => {

  // 01. 包装路由组件当路由切换时调用 getInitialProps 方法
  wrapperRouteComponent(wrapperRouteComponentHoc)
  
  modifyDOMRender(({ App, appMountNode }) => {
    return new Promise(resolve => {
      // 02. 客户端渲染 | 兼容 hydrate 和 render
      ReactDOM[window.__SSR__ ? 'hydrate' : 'render'](<App />, appMountNode, resolve);
    })
  });
}

export default module;


wrapperRouteComponentHoc 实现:

function wrapperRouteComponentHoc(RouteComponent) {
  const WrapperRouteComponent = (props) => {
    const { staticContext } = props;
    // 通过 StaticRouter 提供的 staticContext 获取服务端渲染时透传的数据
    const { initialData } = staticContext;
    const [data, setData] = useState(initialData);

    useEffect(() => {
      // 服务端渲染直出的情况,客户端不再重复调用 getInitialProps
      if (window.__INIT_DATA__) {
        // 只有在首次进入页面需要将 window.__INITIAL_DATA__ 作为 props,路由切换时不需要重新请求
        // 使用后将服务端直出数据设置为空,否则其他页面会使用
        props = Object.assign({}, props,  window.__INITIAL_DATA__)
        window.__INIT_DATA__ = null
      } else {
        // 服务端没有直出数据,前端主动调用 getInitialProps
        if (RouteComponent.getInitialProps) {
          (async () => {
            const data = await RouteComponent.getInitialProps();
            setData(data);
          }());
        }
      }
    }, [])

    return <RouteComponent  {...props, ...data} />;
  }

  return WrapperRouteComponent;
}

Store SSR

TODO

讨论

• html template 模版 - 差异化内容在框架层面收敛(比如 pageConfig)
• 渲染入口问题, createApp 同构,支持服务端 render
• server 构建需要考虑 externals 问题
• html 模版,分内置 html 和 SSR 两个场景
• initialProps 组件初始化问题,getInitialProps 执行是否阻塞问题,分应用级和路由级分情况看
• initialProps 逻辑不应该 plugin-store 中调用,其他地方也会消费这份数据
• createApp 流程重新梳理

  • 区分 SEO 场景和性能
    • 性能场景:应用级别定义,比如支持服务端可以直接 DB 拿数据的逻辑
    • SEO 场景:路由级别定义

Support MPA

  • plugin-mpa: 基于插件实现多页应用

框架 types 导出

场景

app.ts

import { IAppConfig,  } from 'ice/types';

const appConfig: IAppConfig = {
};

routes.ts

import { IRoutes } from 'ice/types';

const routes: IRoutes = {
};

export default routes;

TODO

  • 支持插件改 types

plugin-config 重构方案

约定读取 configs/config.[j|t]s

config 定义如下:

const config = {
  dev: {
    baseUrl: 'dev/',
  },
  prod: {
    baseUrl: 'prod/',    
  },
  default: {
    baseUrl: 'default/',
  }
};

export default config;

plugin-config

  1. 根据 --mode 注入响应的 APP_MODE
  2. .ice 目录下生成 config.ts,并导出 config,import { config } from 'ice',config.ts 生成内容如下:
import config from '@/configs/config';

const userConfig = {
  ...(config.default || {}),
  ...(config[process.env.APP_MODE] || {}),
};

export default userConfig;

由于 createApp 初始化前,工程层面已经完成 process.env.APP_MODE 注入和 config.ts 的生成,因此在不同地方 import 应该均可以拿到对应的配置信息

项目中存在routes文件夹, build.json中设置store: false失效

Do you want to request a feature or report a bug?

What is the current behavior?

If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem.

  • icejs version:1.0.15
  • Node verson:v10.15.3
  • Platform:Darwin zhuxingdeMacBookPro.local 19.3.0 Darwin Kernel Version 19.3.0: Thu Jan 9 20:58:23 PST 2020; root:xnu-6153.81.5~1/RELEASE_X86_64 x86_64
  • build.json
{
  "store": false,
  "plugins": [
    "build-plugin-icestark",
    [
      "build-plugin-moment-locales",
      {
        "locales": ["zh-cn"]
      }
    ]
  ],
  "babelPlugins": ["@babel/plugin-transform-modules-commonjs"],
  "proxy": {
    "/pai/autodl": "http://pre-pai.data.aliyun.com",
    "/pai/common": "http://pre-pai.data.aliyun.com",
    "/pai/thirdparty": "http://pre-pai.data.aliyun.com",
    "/": {
      "secure": false,
      "target": "http://pre-pai.data.aliyun.com"
    }
  }
}
  • src/app.(ts|js)
import santa from '@ali/santa';
import { bootstrap } from '@ali/starry-core';
import { Tracker } from './common/module/startup/tracker';
import { removeStrangeBodyPaddingLeftByIntervallyChecking } from './common/patch/aliyun-header';
import * as config from './config';
import intl from './core/intl';
import { container } from './injector';
import { configHTTP } from './injector/binding/http';
import routes from './routes'; // eslint-disable-line
import { ctokenPAI } from './service';
import { Env } from './service/env';
import { AliyunHeaderService } from './service/header/AliyunHeaderSrv';
import { IHeader } from './service/header/header';
import { HeaderInternalImpl } from './service/header/HeaderInternalImpl';
import { Switcher } from './service/injectable/switcher';
import { URLState } from './service/nav/url-state';

import './styles/index.scss';

async function main() {
  const urlState = container.get(URLState);
  const projectId = urlState.getQuery('prjId');
  urlState.setQuery({
    projectId
  });

  const env = container.get(Env);

  if (env.isInternal()) {
    configHTTP();
    container.bind(IHeader).to(HeaderInternalImpl);
  } else {
    container.bind(IHeader).to(AliyunHeaderService);
  }
  bootstrap(config);

  const header = container.get(IHeader);
  const tracker = container.get(Tracker);
  const switcher = container.get(Switcher);

  await switcher.init();
  tracker.init();

  window.__ALIYUN_HEADER_SERVICE__ = header; // 兼容老代码
  removeStrangeBodyPaddingLeftByIntervallyChecking();

  // Init app
  const app = santa();

  // Start app
  const render = () => {
    app.router(routes);
    app.start('#app');
  };

  if (module.hot) {
    module.hot.accept('./routes', () => {
      render(); // re-require is not nesseary. See https://github.com/gaearon/react-hot-loader/tree/master/docs#starter-kits
    });
  }

  // await ctokenPAI.init();
  Promise.all([
    intl.ready(), // 多语言
    ctokenPAI.init(),
    // aliyunHeaderService.init(),
    header.init()
  ])
    .then(render)
    .catch(err => {
      console.log(err);
      render();
    });
}

main();

What is the expected behavior?
image

webpack的react和react-dom配置了错误的alias

错误现象

详见代码行数:

config.resolve.alias.set(depName, aliasPath);

这样配出来的alias实际是:

{
  "alias": {
    "react-dom": "/node_modules/react-dom/index.js"
  }
}

如果有其他模块引用react-dom里面的子模块,就会出错,例如:

 require('react-dom/server');
// 出错信息:
// Module not found: Error: Can't resolve 'react-dom/server' in '/xxxxx'

解决办法

// 将plugin-core/src/index.ts中下面这行
config.resolve.alias.set(depName, aliasPath);

// 改为
config.resolve.alias.set(depName, path.dirname(aliasPath));

routes 支持 lazy 加载

  • 增强能力:最好能把 usePage/models 这些挂到 Page 上
  • 配置式路由要手动在 routes 配置文件中进行 lazy,但是还是要 Router 组件支持开启 Suspense

MPA + fusion组合使用报错

报个bug,MPA+ fusion组合使用, BasicLayout使用useModel,获取接口,编译正常,但浏览页面报如下错误:

createContainer.js:17 Uncaught Error: Component must be wrapped within a Provider.
    at createContainer.js:17
    at useModelState (createStore.js:38)
    at Object.useModel (createStore.js:49)

If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem.

  • icejs version:1.0.14
  • Node verson:10.8.0
  • Platform:mac os
  • build.json
{
  "outputDir": "dist",
  "devPublicPath": "/",
  "hash": true,
  "mpa": true,
  "publicPath": "/mini/admin/",
  "outputAssetsPath": {
    "js": "",
    "css": ""
  },
  "plugins": [
    [
      "build-plugin-fusion",
      {
        "themePackage": "@alifd/theme-design-pro"
      }
    ]
  ]
}
  • src/app.(ts|js)

页面在没有使用mpa方式时,可以正常浏览

[RFC] 使用 Hooks 来实现组件的生命周期

需求背景

  • 对于老的 Class Component 易于迁移;
  • 对于习惯生命周期思维的用户容易理解以及实现的统一;
  • 对于互转研发模式可对齐搭建 API。

提案

参考:

APIs:

  • useUpdate <=> componentDidUpdate
  • useMount <=> componentDidMount
  • useUnmount <=> componentWillUnmount
  • useRender <=> render (只针对互转场景,本质是一个立即执行的函数)

使用:

import { useMount, useUnmount } from 'ice';

讨论点

是创建一个新插件(plugin-lifecycles?)来实现这些对应生命周期的 Hooks 还是实现到 plugin-core?

Lazy Component 获取不到组件静态属性

问题描述

  1. 路由定义
import { lazy } from 'react'

const PageA = lazy(() => import('./page/A'))
const PageB = lazy(() => import('./page/B'))

const routerConfig = [
   {
      path: '/a',
      component: PageA
  },
 {
      path: '/b',
      component: PageB
  },
]
  1. 调用 wrapperRouteComponent 包装 PageComponent 时无法获取组件的静态属性
wrapperRouteComponent(wrapperPage)

export function wrapperPage(PageComponent) {

  // 获取不到 pageConfig
  const { pageConfig } = PageComponent;
  const { title, scrollToTop } = pageConfig || {};
  // ...
}

问题分析

将组件 lazy 之后,lazy 将传入的参数封装成一个 LazyComponent,大致实现如下:

export type LazyComponent<T> = {
  $$typeof: Symbol | number, 
  _ctor: () => Thenable<{default: T, ...}, mixed>,  // 对应组件的文件资源
  _status: 0 | 1 | 2,  // dynamic import 的状态
  _result: any,  // 加载之后返回组件的文件资源
  ...
};

// lazy component 实现 https://github.com/facebook/react/blob/e706721490e50d0bd6af2cd933dbf857fd8b61ed/packages/shared/ReactLazyComponent.js#L42

// 大致代码如下
function readLazyComponentType(lazyComponent) {
  const status = lazyComponent._status;
  const result = lazyComponent._result;
  switch (status) {
    case Resolved: {   // Resolve 时,呈现相应资源
      const Component = result;
      return Component;
    }
    case Rejected: { // Rejected 时,throw 相应 error
      const error = result;
      throw error;
    }
    case Pending: {  //  Pending 时, throw 相应 thenable
      const thenable = result;
      throw thenable;
    }
    default: { // 第一次执行走这里
      lazyComponent._status = Pending;
      const ctor = lazyComponent._ctor;
      const thenable = ctor();  //  Promise 机制
      thenable.then(moduleObject => {  // ... })
  }
}

如上,dynamic import 本身类似 Promise 的执行机制, 也具有 Pending、Resolved、Rejected 三种状态,lazy Component 结构如下

image

lazy 执行完成之后可得到完整的组件信息

image

解决方案

import { lazy } from 'react'

const PageA = lazy(() => import('./page/A'))
const PageB = lazy(() => import('./page/B'))

// 约定式和配置式路由在 lazy 的场景下都需要工程处理
// 会打包两份?
import _PageA from './page/A'
import _PageB from './page/B'

const routerConfig = [
   {
      path: '/a',
      component: PageA,
       // 扩展字段
       static: { pageConifg: _pageA.pageConfig, name: _PageA.name }
  },
 {
      path: '/b',
      component: PageB,
      // 扩展字段
     static: { pageConifg: _pageA.pageConfig, name: _PageA.name }
  },
]

非路由文件中 lazy 加载组件方式

问题

如果存在非路由文件中有使用 lazy 按需加载组件的需求,需要按 React.lazy 的方式加载不能直接使用 ice 中导出的 lazy 方法

预期结果

使用 lazy 加载组件的方式需要保持一致

lazy 开启后不同 bundle 被统一引入

问题原因

目前开启按需加载做法如下:

const Dashboard = import('@/pages/Dashboard');
const Home = import('@/pages/Home');

所有的路由依赖,在 routes 文件中统一执行 dynamic import,这样处理虽然可以达到分包目的,但是不会按需加载

方案

function lazy(importFunc) {
   return {
       '__LAZY__': true,
       importFunc,
   };
}

const Dashboard = lazy(() => import('@/pages/Dashboard'));

dynamic import 语句包装成方法,在加载具体组件的时候才会执行。
添加一层 lazy 的目的是需要让 Router 渲染的时候知道这个方法是一个 dynamic import 语句(因为 sfc 组件也是一个方法)

判定为需要 lazy 的时候再执行真实 React.lazy 包裹:

React.lazy(() => importFunc().then((m) => {
    if (routerWrappers && routerWrappers.length) {
      const { default, ...rest } = m;
      return { ...rest, default: wrapperRoute(m.default, routerWrappers) }
    }
    return m;
}))

MPA 应用实践和文档优化

问题

  1. 针对 MPA 应用文档没有提供 Layout 以及 Layout 用到的状态数据的说明
  2. examples/basic-mpa 示例需要基于文档进行完善

方案

推荐 Layout 跟着对应的页面,Layout 使用的数据定义在当前页面的 models/* 进行管理

  ├── src/
  │    ├── Dashboard/
  │    │   ├── models/        # 状态管理
+ │    |   ├── layout.js
  │    │   ├── app.js
  │    │   └── index.jsx
  │    └──  Home/
  │         ├── models/
+ │         ├── layout.js
  │         ├── app.js
  │         └── index.jsx
  ├── build.json
  ├── package.json

为什么 Layout 不是定义在 src/layouts 目录下,对应的数据维护在 src/models 下?

考虑不同的页面 layout 以及数据存在异同,会导致 models 数据打包了多余的问题

使用页面状态提示找不到'ice/Home'这个模块

按照官网提供的页面状态使用案例进行测试,项目运行时发生错误Module not found: Can't resolve 'ice/Home'
全局状态userState可以正常使用,页面状态pageState无法使用

import React from 'react';
import { store as appStore } from 'ice';
import { store as pageStore } from 'ice/Home';
import { ResponsiveGrid } from '@alifd/next';


const Analysis = () => {
  const [userState] = appStore.useModel('app');
  const [ pageState, pageAction ] = pageStore.useModel('default');
  console.log(userState);
  console.log(pageState);
  return (
    <ResponsiveGrid gap={20}>
    </ResponsiveGrid>
  );
};

export default Analysis;

Module not found: Can't resolve 'ice/Home'

基于IceStark 在createApp 怎样注册钩子函数?怎么使用自定义AppRoute?

原来自定义iceStark 路由切换钩子函数,同时使用自定义AppRoute 的demo

export default function App() {
	const [pathname, setPathname] = useState();
	const [ visiable, setVisiable] = useState(false);

	function handleRouteChange(newPathname, query) {
		setPathname(newPathname);
	}
	function handleAppLeave(appConfig) {
		console.log('eAppLeavel', appConfig);
	}
	function handleAppEnter(appConfig) {
		const { path } = appConfig;
		setPathname(path);
		console.log('handleAppEnter', appConfig);
	}

	return (
		<BasicLayout pathname={pathname}>
			<AppRouter
				NotFoundComponent={NotFound}
				LoadingComponent={PageLoading}
				onRouteChange={handleRouteChange}
				onAppLeave={handleAppLeave}
				onAppEnter={handleAppEnter}
			>
				{
					appRoutes && appRoutes.length ? (
						appRoutes.map((route, index) => (
							route.path === '/' ? (
								<CommonChildRoute1
									key={index}
									hashType
									{...route}
								>
								</QianniuContainer>
							) : (
								<CommonChildRoute2
									key={index}
									hashType
									{...route}
								/>
							)
						))) : null
				}
			</AppRouter>
			<Loading visible={visiable}/>
		</BasicLayout>
	);
}

使用createApp 如何实现呢?

win10 控制台报错的时候路径丢失了 /

来自 @shibxzalibaba/ice#3076

info WEBPACK             df3fa8cf949fe5c0a15a.hot-update.json   47 bytes          [emitted] [immutable] [hmr]
info WEBPACK                                      favicon.png   4.83 KiB          [emitted]
info WEBPACK                                        index.css   13.5 KiB   index  [emitted]                    index
info WEBPACK                                    index.css.map    125 KiB   index  [emitted] [dev]              index
info WEBPACK         index.df3fa8cf949fe5c0a15a.hot-update.js   1.43 KiB   index  [emitted] [immutable] [hmr]  index
info WEBPACK     index.df3fa8cf949fe5c0a15a.hot-update.js.map  974 bytes   index  [emitted] [dev]              index
info WEBPACK                                       index.html  446 bytes          [emitted]
info WEBPACK                                         index.js   3.04 MiB   index  [emitted]                    index
info WEBPACK                                     index.js.map   2.99 MiB   index  [emitted] [dev]              index
info WEBPACK
info WEBPACK     ERROR in ./.ice/createApp.tsx
info WEBPACK     Module not found: Error: Can't resolve '`D:workjsicework1study11`
info WEBPACK     ode_moduleuild-plugin-ice-corelibmodule.js' in 'D:\work\js\ice\work1\study11\.ice'
info WEBPACK      @ ./.ice/createApp.tsx 18:23-111
info WEBPACK      @ ./.ice/index.ts
info WEBPACK      @ ./src/app.jsx
info WEBPACK      @ multi ./src/app

image

[RFC] [plugin-store] 提供组件级别的 Model 支持

需求背景

  • 保持应用对状态管理处理方式的一致性(如果愿意整个应用都可以使用声明式模型来表达状态);
  • 互转模式下的 API 对齐。

提案

目录结构

└── src/
    └── pages
        └── Home
            └── components
                └── Todos
                    ├── model.ts
                    └── index.ts

模型定义

// src/pages/Home/components/Todos/model.ts
export default {
  state: {
    name: '',
  },
  reducers: {
    setName: function(prevState, name) {
      return {
        ...prevState,
        name,
      };
    }
  }
}

模型消费

// src/pages/Home/components/Todos/index.ts
import { model } from '$';
export default function() {
  const [state, actions] = model.useValue();

  return (
    <div>
      {state.name}
      <button onClick={() => {
        actions.setName('alvin');
      }}>click me</button>
    </div>
  );
}

[RFC] 权限方案设计

整体能力决定实现到 plugin-ice-auth 里,插件默认不内置。对比实现到 plugin-router 里:

优点:

  • 不影响 bundle 大小
  • 逻辑分离好维护

缺点:

  • 多包一层 HOC

初始化用户角色

src/app.ts 中设置当前用户的角色:

import { createApp, helpers, IAppConfig } from 'ice';
import NoAuthComponent from '@/components/NoAuthComponent';
const { cookie } = helpers;

const appConfig: IAppConfig = {
  app: {},
  auth: {
    setRole: () => {
      // 服务端将当前用户角色 role 保存在 cookie 中,或者通过全局变量等方式输出
      const { role } = cookie.parse(document.cookie);
      return role;
    },
    // 可选,配置无权限时的视图组件,默认有一个
    NoAuthComponent,
  }
};

createApp(appConfig);

异步获取角色

外部很多公司的 html 是托管在 nginx 这一层的,同步访问 html 时没有任何鉴权,鉴权/登录逻辑是在前端代码里调用的,通过 app.getInitalData 实现,app.getInitalData 有点像应用级别的支持同步/异步的 setup,可以考虑作为通用能力而不仅是服务于 SSR。大概的用法:

import { createApp, helpers, IAppConfig } from 'ice';
import NoAuthComponent from '@/components/NoAuthComponent';
const { cookie } = helpers;

const appConfig: IAppConfig = {
  app: {
    getInitialData: async () => {
      const role = await request('/api/getRole');
      return { role };
    }
  },
  auth: {
    setRole: (initialData) => {
      return initialData.role;
    },
  }
};

页面/路由权限

通过 pageConfig.roles 声明当前页面允许哪些角色访问:

import React from 'react';

const Home = () => {
  return <>Home Page</>;
};

Home.pageConfig = {
  // 允许的角色,若不配置则代表所有角色都可以访问
  roles: ['guest', 'admin'],

  // 对 roles 的补充,控制能力更强
  hasAuth: () => { return true; }
};

export default Home;

组件级权限

提供 getRole() API 获取当前用户的角色:

import { getRole } from 'ice';

function CustomComponent() {
  const role = getRole();
  return (
    <>
        <div>当前用户角色:{role}</div>
        { role === 'admin' ? <span>确定</span> : null }
        { ['guest', 'admin' ].indexOf(role) !== -1 ? <span>更新</span> : <span>无权限</span> }
    </>
  );
}

菜单权限

业务代码结合 API getRole() 自己消化

操作权限抽象

不需要框架做什么事情,一个最佳实践

@todo

相关链接

关于 `ice/pageName` 设计

现状

针对 store 的设计支持了通过 ice/pageName 的引用方式,如下:

import { store } from `ice/${pageName}`

问题

上述方案会引入一些问题:

  1. 未支持 store 的按需加载
// .ice/pageStores.ts
//  导出所有页面定义的 store
import About from './pages/About/store';
import Home from './pages/Home/store';

const pageStores = { About,Home, };
export default pageStores;

// plugin-store/src/module.tsx
//  问题:一次性将所有 store 都进行导入,然后按照路由追加匹配的 Provider
// TODO:需要支持按需加载
import PageStores from '$ice/pageStores';
const wrapperComponent = (PageComponent) => {
  const { pageConfig = {} } = PageComponent;
  const StoreWrapperedComponent = (props) => {
    const pageComponentName = pageConfig.componentName;
    const PageStore = PageStores[pageComponentName];
    if (PageStore) {
      return (
        <PageStore.Provider initialState={pageConfig.initialStates}>
          <PageComponent {...props}/>
        </PageStore.Provider>
      );
    }
    return <PageComponent {...props} />;
  };
  return StoreWrapperedComponent;
};
  1. 无法扩展出其他的二级引用引用,如 import { IAppConfig } from 'ice/types'
// plugin-core/src/index.ts
onGetWebpackConfig((config: any) => {
    // 指定到 ./ice/index.ts
    config.resolve.alias.set('ice$', path.join(iceTempPath, 'index.ts'));
    // 问题:指定到 .ice/pages/* 下导致无法在扩展出其他二级路径,如 ice/types
    config.resolve.alias.set('ice', path.join(iceTempPath, 'pages'));
}
  1. 通过页面名称作为 ice 的二级引用,对用户存在一些困扰,以及实现上不太优雅,最好是 all from ice (与 2 冲突?)

启动服务构建 69% 卡主

问题

  1. 执行 icejs start 一直卡在 69% 无法编译通过
  2. 退出服务重新执行上一个端口被占用(没退出)

demo

lazy 并未按需打包

问题

使用 lazy 之后有分包,但并未按需打包,因为 import 语句在 Router 里进行调用了,具体如下:

  1. 使用 dynamic import 语法进行分包引用
const Notfound = import('@/pages/NotFound');
  1. 但是在 Router 组件里提前进行使用了,也就是都进行引用了
function getRouteComponent(component, routerWrappers?: IRouteWrapper[]) {
  // 在这里调用了
  return isPromise(component) ? React.lazy(() => (component as IImport).then((m) => {
    if (routerWrappers && routerWrappers.length) {
      m.default = wrapperRoute(m, routerWrappers);
    }
    return m;
  })) : wrapperRoute(component, routerWrappers);
}
  1. 应该要在具体组件加载的时候调用 import,这也是为什么 lazy 加载是这样写的
const Notfound = lazy(() =>  import('@/pages/NotFound'));

[RFC] 集成数据服务(services)

需求背景

数据请求是构建 UI 应用的必要步骤。

icejs 当前数据请求有以下几种支持方式(docs):

  • request:背后是 axios ,是对 HTTP 协议的抽象和客户端兼容性和封装;
  • useRequest:集成了请求状态,是前者的超集。

以上都是使用时进行传参数据处理

在实际业务场景中,有以下需求:

  • 其他请求类型的支持,例如 jsonp/mtop;
  • 集中式的服务声明、数据检查和处理。

提案

在全局添加 services/ 文件夹用于进行服务声明。

声明数据源

// services/todos
import PropTypes from 'prop-types';

const config = {
  type: 'fetch', // [可选] fetch/mtop/jsonp

  options: {
    baseUrl: 'https://mocks.alibaba-inc.com/mock/D8iUX7zB',
    method: 'get',
    timeout: 3000,
  },

  // 接口参数的类型
  params: {
    pageNum: PropTypes.number,
  },

  // 接口返回值类型
  response: {
    success: PropTypes.bool.isRequired,
    data: PropTypes.object.isRequired,
    errorCode: PropTypes.string,
    errorMsg: PropTypes.string,
  },

  // 对接口返回值进行处理的函数
  dataHandler(response) {
    return response.data;
  },
};

const getAll = {
  options: {
    url: '/getAll',
  },
  response: {
    data: PropTypes.arrayOf(
      PropTypes.shape({
        id: PropTypes.string.isRequired,
        title: PropTypes.string.isRequired,
        done: PropTypes.bool,
      })
    ),
  },
};

const addTodo = {
  options: {
    url: 'https://mocks.alibaba-inc.com/mock/D8iUX7zB/addTodo', // 直接设置 url
    method: 'post',
    timeout: 5000,
    headers: { },
  },
  params: {
    title: PropTypes.string.isRequired,
    done: PropTypes.bool
  },
  response: {
    data: PropTypes.shape({
      id: PropTypes.string.isRequired,
      title: PropTypes.string.isRequired,
      done: PropTypes.bool,
    }),
  },
};

// 具体接口的配置将是 Object.assign({}, config, methods[key]);
export default {
  config,
  methods: {
    getAll,
    addTodo,
  },
};

消费数据源

import { services } from 'ice';
const { todo: todoService } = services;

const data = await todoService.getAll();

// iceluna API 对齐
todoService.getAll.status;  // 上次请求状态 init/loading/loaded/error
todoService.getAll.data; // 上次请求成功后的数据
todoService.getAll.error; // 上次请求失败的错误对象

补充互转场景说明

转换示例

源码:

参考上面的声明数据源

搭建协议:

// 数据源声明参考:https://yuque.antfin-inc.com/mo/spec/spec-low-code-building-schema#1huiy
export default {
   id: 'todosGetAll',
  // 请求类型:fetch(本域)/jsonp(跨域)/mtop(本域)
   type: 'json',

   isInit: false; // 是否为初始数据

   // 请求参数:每种请求类型对应不同参数
   options: {
     uri: 'https://mocks.alibaba-inc.com/mock/todos/getAll', // 请求地址
     method: 'GET', // 请求方法
     params: { // 请求参数

     }, 
     headers: { // 请求头信息

     },
     timeout: '', // 超时时长
  },

  // 数据处理函数
  dataHandler: function(response, error) {
    return response.data;
  },
};

与搭建基础协议的 API 对齐

在搭建基础协议中,数据源可以与组件的生命周期组件的状态进行绑定。为了能够在互转场景中实现 API 对齐,故新增以下提案。

└── src/
    └── pages
        └── Home
            └── components
                └── Todos
                    ├── service.ts
                    ├── model.ts
                    └── index.ts

service.ts

const user = {
  isInit: true, // 互转字段:初始化数据
  options: {
    url: '//user.com',
    method: 'get',
  },
  dataHandler(response, error): User {
    return response.data;
  },
};

const todos  = {
  options: {
    url: '//todos.com',
    method: 'get',
  },
  dataHandler(response, error): Todo[] {
    return response.data;
  },
};

interface DataMap { user: User }

export default {
  config: {
    // ...
  },
  request: {
    todos,
    user
  },

  // 互转字段,用户处理初始化请求的数据
  dataHandler: function(dataMap: DataMap, error): DataMap {
    return dataMap;
  },
};

dataMap 是对象,key 是数据的 id, value 是单个请求的结果。

model.ts

export default {
  state: {
    todos: [],
    user: {},
  },
};

index.ts

import { service, model } from '$';

function Todos() {
  const [ state ] = model.useValue();
  service.useInit(); // 1, 组件 didMount 时会自动请求数据,并将数据更新到 model 中;用户手动调用 service.load 时会将数据更新到 model 中。

  function handleClick() {
    service.reloadInit();
  }

  return (
    <div>
      {state.user.name}
      <button onClick={handleClick}>
        刷新
      </button>
    </div>
  );
}

useRequest API

需求

  • 增强 plugin-request 的能力将其作为 icejs 的内置方案
  • 增强现有 useRequest 的能力,参考 SWR

useRequest

API

import { useRequest } from 'ice'

const { data, error, loading, request } = useRequest(config, options?); 

Parameters

  • config: 对等 axios config
  • options: (可选的) 参数配置

Return Values

  • data: 请求结果
  • error: 请求错误
  • isLoading: 加载中
  • request: 手动调用请求

Options(对比 SWR,划掉的为不需要实现的)

  • suspense = false: enable React Suspense mode (details)
  • refreshInterval = 0: polling interval (disabled by default)
  • refreshWhenHidden = false: polling when the window is invisible (if refreshInterval is enabled)
  • refreshWhenOffline = false: polling when the browser is offline (determined by navigator.onLine)
  • shouldRetryOnError = true: retry when fetcher has an error (details)
  • loadingTimeout = 3000: timeout to trigger the onLoadingSlow event
  • errorRetryInterval = 5000: error retry interval (details)
  • onLoadingSlow: callback function when a request takes too long to load (see loadingTimeout)
  • onSuccess: callback function when a request finishes successfully
  • onError: callback function when a request returns an error
  • onErrorRetry: handler for error retry
  • fetcher = undefined: the default fetcher function
  • initialData: initial data to be returned (note: This is per-hook)
  • revalidateOnFocus = true: auto revalidate when window gets focused
  • revalidateOnReconnect = true: automatically revalidate when the browser regains a network connection (via navigator.onLine)
  • dedupingInterval = 2000: dedupe requests with the same key in this time span
  • focusThrottleInterval = 5000: only revalidate once during a time span

说明:

  1. 需要返回 request 以此提供手动调用的时机会更为直接简单
  2. 相比 useSWR 和 useAPI 而言,都是在 useXXX 即调用,而 useAPI 提供了 manual 和 run 供手动调用

useSWR 如下:

参考:https://github.com/zeit/swr

import useSWR from 'swr'

const { data, error, isValidating, revalidate } = useSWR(key, fetcher?, options?)

按需加载开启后 Suspense 组件问题

Do you want to request a feature or report a bug?

bug

What is the current behavior?

按需加载开启后,每次加载资源页面都会刷新

What is the expected behavior?

按需加载资源时,全局 layout 不应该刷新

启动调试服务时 IP 地址信息丢失

Do you want to request a feature or report a bug?

What is the current behavior?

如下未显示 IP 地址的形式:

image

  • icejs version:
  • Node verson:
  • Platform:
  • build.json
  • src/app.(ts|js)

What is the expected behavior?

image

启动输出日志上报错误

Do you want to request a feature or report a bug?

What is the current behavior?

启动输出日志上报错误

  • icejs version:
  • Node verson:
  • Platform:
  • build.json
  • src/app.(ts|js)

image

What is the expected behavior?

  • 上报日志即便挂了也不要透出日志

开发 & 发布流程规范

固定分支

  • master:默认主干分支,不允许直接提交代码,只接受 release 分支和 hotfix 分支的代码,所有代码通过 MR 合并到 master
  • release: 预发布分支,迭代计划中的新功能和问题修复都在这个分支

分支规范

  • feat/xxx:新增功能时需要以 feat/xxx 命名分支,如 feat/support-ssr
  • fix/xxx:bug 修复时以 fix/xxx 命名分支,如 fix/router-lazy
  • hotfix/xxx:紧急修复分支,非迭代计划中的代码提交,该分支一般用于紧急修复生产环境问题,最终直接合并回 master 分支,并同步会 release 分支,该分支以 hotfix/xxx 命名,如 hotfix/syntax-error
  • docs/xxx:文档分支,新增文档和文档改动时需要以 docs/xxx 命名分支,如 docs/add-ssr-docs

其他详见 GIT_COMMIT_SPECIFIC.md

分支合并

  • release -> master: 从预发分支合并到主分支时请选择 merge 方式,保留此次 release 上的变更记录
  • hotfix/xxx -> master: 从补丁分支合并到主分支时请选择 squash 方式,保留此次 hotfix 上的变更记录
  • <type>/xxx -> release :合并到 release 的分支请统一选择 squash 方式,squash 可以对 <type>/xxx 上的 commit 进行合并并且能重新书写一个更加具体的 commit-message

发布规范

版本定义

  • Latest 版本 即用于生产环境的正式版本,需要创建 tag 并编写 release changelog
  • Beta 版本 即用于测试环境的先行版本,不需要创建 tag 和不需编写 release changelog

正式版本发布

  • 发布时间: 每周三下午 5 点
  • 需求截止日期: 每周一下午 6 点
  • 发布人: @chenbin92
  • 发布流程:
    1. 发布分支位于 release 预发分支, 准备发布前将预发分支的 WIP 标记删除
      2. 将本地代码切到需要发布的 release 预发分支并更新最新代码(脚本做预发检查)
    2. 执行 npm run publish 进行正式版本的发布
    3. 发布成功后合并代码到 master
    4. 编写 changlogs
# 发布目录
$ cd icejs

# 发布流程:
# 1. 检查本地是否有未提交代码,如果有则禁止发布
# 2. 构建生产环境的 lib 包
# 3. 输入需要发布的版本(版本不能包含 rc、beta、alpha 信息否则 dist-tag 会指向 beta)
# 4. 开始发布版本到 NPM 
# 5. 发布成功自动提交本地代码和创建 Tag
# 6. 同步包到国内 NPM 镜像源
$ npm run publish

beta 版本发布

  • 发布时间: 时间不定可根据功能需求按需发布
  • 需求截止日期: 时间不定
  • 发布人: @chenbin92
  • 发布流程:
    1. 发布分支位于 release 预发分支
    2. 执行 npm run publish 进行正式版本的发布
# 发布目录
$ cd icejs

# 发布流程与正式版本差异如下:
# ...
# 3. 输入需要发布的版本(版本必须包含 rc 或者 beta 或者 alpha 信息之一否则 dist-tag 会指向 latest)
# ...
# 5. 发布成功自动提交本地代码(非正式版本不会创建 Tag)
# ...
$ npm run publish

hotfix 紧急发布

紧用于正式版本上出的重大 bug 需要进行修复时

  • 发布时间:时间不定可根据紧急程度按需发布
  • 需求截止日期:时间不定
  • 发布人:@imsobear @chenbin92
  • 发布流程:
    1. 发布分支位于 hotfix 补丁分支
    2. 执行 npm run publish 进行版本的发布
    3. 发布成功后合并代码到 master
    4. 编写 changlogs
# 发布目录
$ cd icejs

# 发布流程与正式版本一致
$ npm run publish

回滚策略

  • 仓库采用 Lerna Independent 模式,当某个包需要回滚时,将当前包的 dist-tag 指向某个可以版本即可
# 如当 build-plugin-ice-store 出现错误需要回滚时
# 需要将 ice.js 和 build-plugin-ice-store 同时进行回滚
$ npm dist-tags add [email protected] latest
$ npm dist-tags add [email protected] latest

发布演示

demo

icejs 文档目录

新手指南

  • icejs 是什么
    • 设计原则
    • 特性
  • 快速开始
  • 常见问题

基础教程

  • 目录结构
  • 样式方案
  • 视图配置
  • 路由
  • 数据请求
  • 状态管理
  • Helpers
  • 日志
  • 应用配置
  • 工程配置

进阶指南

  • 权限管理
  • 主题配置
  • 国际化
  • 按需加载
  • mock 方案
  • Proxy 代理
  • 单元测试
  • 应用部署

框架扩展

  • 插件列表
  • 插件开发

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.