ice-lab / icejs Goto Github PK
View Code? Open in Web Editor NEW仓库迁移至:https://github.com/alibaba/ice
Home Page: https://ice.work/docs/guide/intro
License: MIT License
仓库迁移至:https://github.com/alibaba/ice
Home Page: https://ice.work/docs/guide/intro
License: MIT License
在 React 中通常会使用 react-router 去管理不同的页面切换,通过监听路由的变化匹配到对应的路由组件进行渲染,而未匹配到的组件将会被卸载, 这意味着组件被卸载之后其状态丢失。
但在某些场景下,我们希望切换路由时还能保留组件的状态不被丢失,在下次展示时进行恢复,如:
场景一:
当用户在列表页面进入详情页面后,缓存列表页面的状态,当从详情页面返回列表页面时,能恢复到上次的状态(位置状态)。
场景二:
当用户在填写表单但未提交时、可能因为某些原因需要临时离开当前页面,当从其他页面返回表单页面时,能恢复用户填写的表单信息(数据状态)。
什么是 keep-alive?
将组件保存在内存中,而不是卸载以及重新创建,避免重新渲染。也就是所谓的组件缓存。
通过 keep-alive 可以保存当前页面的数据、状态、滚动条位置及渲染内容。当切换到对应页面时,被保存的页面将直接被渲染,还原为切换前的内容。
被 keep-alive 包含的组件不会被再次初始化,也就意味着不会重走生命周期函数,但是有时候是希望我们缓存的组件可以能够再次进行渲染,因此被包含在 keep-alive 中创建的组件,通常会多出两个生命周期的钩子: activated
与 deactivated
:
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 提供的 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 |
恢复滚动位置 | 支持 | 支持 | 支持 | 支持 |
缓存控制 | 支持 | - | 支持(不友好) | 支持 |
总结 | ★★★★ | ★★ | ★★ | ★★★★ |
主要实现思路:
详见代码
TODO
keep-alive 作为状态保存的一种实现方式,在某些场景如列表位置恢复,表单状态保存等非常有用,且无需重复渲染组件,是一种很好的选择。但当项目和数据复杂的情况下,需要合理使用 keep-alive 进行状态保存,以及数据的自动清理等。
目前仓库使用 lerna fixed 模式,需要改为 Independent 模式进行包版本开发管理,只发布变更的 package
版本: 1.0.6
/bin/sh: 1: icejs: not found
当前模型声明区分了 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);
}, []);
}
在 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
返回的数据,将被作为组件的初始 propsgetInitalProps
会在页面 render 之前被执行,其返回值将作为页面的初始 props 用于渲染。同时这份数据会被放置于页面中getInitialProps
getInitialProps
才会被调用,子组件使用 getInitialProps
是无效的.
├── build
│ ├── index.html
│ ├── css/index.css
│ ├── js/index.js
│ └── server.js
客户端和服务端使用一套路由,服务端通过 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 <% 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>
通过获取到服务端直出的 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;
}
TODO
• html template 模版 - 差异化内容在框架层面收敛(比如 pageConfig)
• 渲染入口问题, createApp 同构,支持服务端 render
• server 构建需要考虑 externals 问题
• html 模版,分内置 html 和 SSR 两个场景
• initialProps 组件初始化问题,getInitialProps 执行是否阻塞问题,分应用级和路由级分情况看
• initialProps 逻辑不应该 plugin-store 中调用,其他地方也会消费这份数据
• createApp 流程重新梳理
Do you want to request a feature or report a bug?
feature
What is the current behavior?
not support
build.json
:src/app.(ts|js)
:What is the expected behavior?
import { useInterval } from 'ice';
对应实现:https://overreacted.io/zh-hans/making-setinterval-declarative-with-react-hooks/
@ClarkXia 再梳理下这块
app.ts
import { IAppConfig, } from 'ice/types';
const appConfig: IAppConfig = {
};
routes.ts
import { IRoutes } from 'ice/types';
const routes: IRoutes = {
};
export default routes;
config 定义如下:
const config = {
dev: {
baseUrl: 'dev/',
},
prod: {
baseUrl: 'prod/',
},
default: {
baseUrl: 'default/',
}
};
export default config;
.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 应该均可以拿到对应的配置信息
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.
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();
build.json
{
"store": false
}
RT,方便写 demo
类似之前的 build-plugin-env-config
详见代码行数:
icejs/packages/plugin-core/src/index.ts
Line 52 in bf2b0a6
这样配出来的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));
报个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.
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方式时,可以正常浏览
参考:
APIs:
使用:
import { useMount, useUnmount } from 'ice';
是创建一个新插件(plugin-lifecycles?)来实现这些对应生命周期的 Hooks 还是实现到 plugin-core?
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
},
]
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 结构如下
lazy 执行完成之后可得到完整的组件信息
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 按需加载组件的需求,需要按 React.lazy 的方式加载不能直接使用 ice 中导出的 lazy 方法
使用 lazy 加载组件的方式需要保持一致
目前开启按需加载做法如下:
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;
}))
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 数据打包了多余的问题
需要考虑下以哪种方式承载透出,目前区块没有这样的能力
按照官网提供的页面状态使用案例进行测试,项目运行时发生错误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'
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>
);
}
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
└── 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>
);
}
整体能力决定实现到 plugin-ice-auth
里,插件默认不内置。对比实现到 plugin-router 里:
优点:
缺点:
在 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()
自己消化
不需要框架做什么事情,一个最佳实践
针对 store 的设计支持了通过 ice/pageName
的引用方式,如下:
import { store } from `ice/${pageName}`
上述方案会引入一些问题:
// .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;
};
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'));
}
使用 lazy 之后有分包,但并未按需打包,因为 import 语句在 Router 里进行调用了,具体如下:
const Notfound = import('@/pages/NotFound');
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);
}
const Notfound = lazy(() => import('@/pages/NotFound'));
数据请求是构建 UI 应用的必要步骤。
icejs 当前数据请求有以下几种支持方式(docs):
以上都是使用时进行传参和数据处理。
在实际业务场景中,有以下需求:
在全局添加 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 对齐,故新增以下提案。
└── src/
└── pages
└── Home
└── components
└── Todos
├── service.ts
├── model.ts
└── index.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 是单个请求的结果。
export default {
state: {
todos: [],
user: {},
},
};
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>
);
}
import { useRequest } from 'ice'
const { data, error, loading, request } = useRequest(config, options?);
config
: 对等 axios configoptions
: (可选的) 参数配置data
: 请求结果error
: 请求错误isLoading
: 加载中request
: 手动调用请求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 eventerrorRetryInterval = 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 successfullyonError
: callback function when a request returns an erroronErrorRetry
: handler for error retryfetcher = undefined
: the default fetcher functioninitialData
: initial data to be returned (note: This is per-hook)revalidateOnFocus = true
: auto revalidate when window gets focusedrevalidateOnReconnect = true
: automatically revalidate when the browser regains a network connection (via navigator.onLine
)dedupingInterval = 2000
: dedupe requests with the same key in this time spanfocusThrottleInterval = 5000
: only revalidate once during a time span说明:
useSWR 如下:
参考:https://github.com/zeit/swr
import useSWR from 'swr'
const { data, error, isValidating, revalidate } = useSWR(key, fetcher?, options?)
官方模板目前主要是偏 UI 的,例如 store/config/request 这些都没有使用,建议内置。
Do you want to request a feature or report a bug?
bug
What is the current behavior?
按需加载开启后,每次加载资源页面都会刷新
What is the expected behavior?
按需加载资源时,全局 layout 不应该刷新
框架内置集成了 react-router-dom,但以下 API 暂未提供,需要暴露出来
master
:默认主干分支,不允许直接提交代码,只接受 release 分支和 hotfix 分支的代码,所有代码通过 MR 合并到 masterrelease
: 预发布分支,迭代计划中的新功能和问题修复都在这个分支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
release -> master
: 从预发分支合并到主分支时请选择 merge 方式,保留此次 release 上的变更记录hotfix/xxx -> master
: 从补丁分支合并到主分支时请选择 squash 方式,保留此次 hotfix 上的变更记录<type>/xxx -> release
:合并到 release 的分支请统一选择 squash 方式,squash 可以对 <type>/xxx
上的 commit 进行合并并且能重新书写一个更加具体的 commit-messagerelease
预发分支, 准备发布前将预发分支的 WIP 标记删除release
预发分支并更新最新代码(脚本做预发检查)npm run publish
进行正式版本的发布# 发布目录
$ cd icejs
# 发布流程:
# 1. 检查本地是否有未提交代码,如果有则禁止发布
# 2. 构建生产环境的 lib 包
# 3. 输入需要发布的版本(版本不能包含 rc、beta、alpha 信息否则 dist-tag 会指向 beta)
# 4. 开始发布版本到 NPM
# 5. 发布成功自动提交本地代码和创建 Tag
# 6. 同步包到国内 NPM 镜像源
$ npm run publish
release
预发分支npm run publish
进行正式版本的发布# 发布目录
$ cd icejs
# 发布流程与正式版本差异如下:
# ...
# 3. 输入需要发布的版本(版本必须包含 rc 或者 beta 或者 alpha 信息之一否则 dist-tag 会指向 latest)
# ...
# 5. 发布成功自动提交本地代码(非正式版本不会创建 Tag)
# ...
$ npm run publish
紧用于正式版本上出的重大 bug 需要进行修复时
hotfix
补丁分支npm run publish
进行版本的发布# 发布目录
$ cd icejs
# 发布流程与正式版本一致
$ npm run publish
# 如当 build-plugin-ice-store 出现错误需要回滚时
# 需要将 ice.js 和 build-plugin-ice-store 同时进行回滚
$ npm dist-tags add [email protected] latest
$ npm dist-tags add [email protected] latest
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.