微前端方案落地(Systemjs,pnpm,Immer)

85 阅读9分钟

简介:

此次微前端方案为解决多tab页情况下使用乾坤导致的样式无法彻底隔离问题而延伸出来的方案。

通过路由带参跳转,根据参数查找通过immer存储微应用配置,根据微应用配置通过基于Systemjs开发的微应用加载组件(LoadApp)进行微应用的加载与渲染。

主要技术实现:

I mmer ** **可持久化不可变结构数据库,用来存储所有注册的微应用config数据(Immer 实用中文文档_衣乌安、的博客-CSDN博客_immer文档

Systemjs ** **用于加载器中,微应用被打包为模块,浏览器不支持模块化,需要使用 systemjs 实现浏览器中的模块化。在开发阶段我们可以使用 ES 模块规范,然后使用 webpack 将其转换为 systemjs 支持的模块。

pnpm包管理工具Fast, disk space efficient package manager | pnpm

前言:

什么是微前端?

微前端是一种将多个可独立交付的小型前端应用聚合为一个整体的架构风格。

微前端有哪些落地方案?

自组织模式:通过约定进行互调,但会遇到处理第三方依赖等问题。

基座模式:通过搭建基座、配置中心来管理子应用。如基于 SIngle Spa 的偏通用的乾坤方案,也有基于本身团队业务量身定制的方案。

去中心模式:脱离基座模式,每个应用之间都可以彼此分享资源。如基于 Webpack 5 Module Federation 实现的 EMP 微前端方案,可以实现多个应用彼此共享资源分享。

具体方案实践:

此次选择使用方案以及技术选型?

基座模式:通过搭建基座、配置中心来管理子应用。如基于 SIngle Spa 的偏通用的乾坤方案。

**子模块项目管理方式: **它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立git-scm.com/book/zh/v2/…

pnpm包管理工具Fast, disk space efficient package manager | pnpm

如何实现微应用调用?​编辑********

基座 ** **大的容器,也是一个独立的微应用,提供容器基于路由挂在子应用

子应用 ** **基于约定式的生命周期,提供独立运行/组合挂载的独立应用

约定式的生命周期:都包含reader()方法,并且能够在各个生命周期中做任何操作

  1. bootstrop( )
  2. mount( )
  3. Update( )
  4. Unmount( )

用于更改immer数据库的组件 ** ** 

**微应用加载器 **:微应用加载的核心组件,基于SystemJS实现;早期基于qiankun实现,因程序的多页签设计和样式隔离问题放弃,后使用Systemjs加载脚本,参考了qiankun源码自定义实现了样式隔离。

**< **LoaderScript **>: **基于Symbol.asyncIterator实现异步迭代器用于动态加载公共脚本,支持了按顺序的依赖加载功能;常用于全局抽象的externals库加载(配合webpackexternals一起使用);内置全局唯一的缓存,不会重复加载;

**预加载< **PreLoad **>: **预加载的微应用,用于加速部分核心业务微应用提高加载速度,原理同,按顺序加载,少占用并发数

**< **AppRoute >: 基于Route实现的支持类似keep-alive的路由,在基于路由的多页签体系中保证路由变化不回导致路由销毁;配合多页签实现了在非激活情况下保持状态不变达到优化性能的目的;一般使用在子微应用中;

**< **AppContraller **> **: 基座的整体页面框子组件,定义了各个不同区域,其中主内容区域用于加载多页签

**< **AppTab **> **: 基于history变化实现了类似浏览器的页签的组件,内置仿浏览器的部分操作;对应页签内容实际是加载微应用,定制需要配合权限体系控制的微应用的加载。

**< **InjectHistory **> **: 协助其他微应用获取基座 history 对象,处理存在子应用无法触发机制 history 变化的问题;实际在全局配置一个history对象,实现了一个自定义的useHistoryhook方便子应用获取

展开看代码:

(用于初始化注册微应用配置以及其他功能所需配置如主题等):

interface.d.ts

export type AppItemType = {
  id: string;
  routerPrefix: string;
  version: string;
  status: 0 | 1 | 2; //App状态1:正常,2:下架,0:删除,微应用下架或者删除时对应的功能项全部停用
  resource: string[]; //App对应脚本静态资源文件路径[js,css]
  icon: string;
  name: string;
  ext?: any;
};

export type FeatureItemType = {
  id: string;
  appId: string;
  name: string;
  status: 0 | 1 | 2; //功能项状态1,正常,2,停用,0删除,当App更新了版后是可能出现功能项缩减的情况
  type: 1 | 2 | 3; //功能类型1:实际菜单,2:权限,3:虚拟分组
  interfaceCodes: string[];
  icon: string;
  routeUrl: string;
  sort: number;
  children?: FeatureItemType[];
  parentId?: string;
  level?: number;
};

export type BSConfigType = {
  systemLogo: string;
  systemName: string;
  parterLogo: string[];
  loginBG: string;
  icp: string[];
  openSocket: boolean;
  chromeDownloadUrl: string;
  AppTimeOut: number;
  shortcut: string;
  download: { chrome: string; player: string };
  [key: string]: any;
};

export type ThemeInfoType = {
  vars: { [key: string]: string[] };
  style: {
    menuType: 'inline' | 'horizontal';
    hasTab: boolean;
  };
};

export type ConfigKey = 'app' | 'feature' | 'featureIds' | 'bs' | 'theme';

export type ConfigJOSN = {
  app: { [key: string]: AppItemType };
  features: FeatureItemType[];
  featureIds: string[];
  bs: BSConfigType;
  theme: ThemeInfoType;
  registerAppConfig: (conf: AppItemType[]) => void;
  registerFeatrueIds: (conf: string[]) => void;
  registerBSConfig: (conf: BSConfigType) => void;
  registerPlatformFeature: (conf: FeatureItemType[]) => void;
  registerThemeConfig: (conf: ThemeInfoType) => void;
  getThemeVarValue: (themeKey: string) => string;
  [key: string]: any;
};

index.ts

//可持久化和不可更改的数据结构库
import produce from 'immer';
import { AppItemType, BSConfigType, ConfigJOSN, FeatureItemType, ThemeInfoType } from './interface';
import { insertThemeStyle } from './utils';

//最终抛出的Config对象
const Config: ConfigJOSN = {
  //初始化值

  //所有各个微应用配置
  app: produce({}, () => {}),
  //系统配置
  bs: produce({} as BSConfigType, () => {}),
  //用户功能菜单
  features: produce([], () => {}),
  //用户功能菜单ids
  featureIds: produce([], () => {}),
  //平台配色
  theme: produce({} as ThemeInfoType, () => {}),

  //注册修改各个值的方法
  registerAppConfig: (app: AppItemType[] = []) => {
    Config.app = produce(Config.app, (draft: { [key: string]: AppItemType }) => {
      app.forEach((item) => {
        draft[item.routerPrefix] = item;
      });
    });
  },
  registerFeatrueIds: (featureIds: string[] = []) => {
    Config.featureIds = produce(Config.featureIds, (draft: string[]) => {
      featureIds.forEach((id) => {
        const index = draft.findIndex((v) => v === id);
        if (index === -1) {
          draft.push(id);
        } else {
          draft[index] = id;
        }
      });
    });
  },
  registerBSConfig: (bs = {} as BSConfigType) => {
    Config.bs = produce(Config.bs, (draft: BSConfigType) => {
      Object.keys(bs).forEach((key) => {
        draft[key] = bs[key];
      });
    });
  },
  registerPlatformFeature: (features: FeatureItemType[] = []) => {
    Config.features = produce(Config.features, (draft: FeatureItemType[]) => {
      features.forEach((item) => {
        const index = draft.findIndex((v) => v.id === item.id);
        if (index === -1) {
          draft.push(item);
        } else {
          draft[index] = item;
        }
      });
    });
  },
  registerThemeConfig: (theme = {} as ThemeInfoType) => {
    insertThemeStyle(theme);
    Config.theme = produce(Config.theme, (draft: ThemeInfoType) => {
      Object.keys(theme).forEach((key) => {
        draft[key] = theme[key];
      });
    });
  },

  getThemeVarValue: (themeKey: string) => {
    return getComputedStyle(document.querySelector(':root')).getPropertyValue(`--${themeKey}`);
  },
};

//缓存
const LMConfig: typeof Config = (function () {
  if (window['_CONFIG_']) {
    return window['_CONFIG_'];
  } else {
    window['_CONFIG_'] = Config;
    return Config;
  }
})();

export default LMConfig;

(用于查询微应用配置以及其他配置):

utils.ts

import { Service } from '@cloud-app-dev/vidc';

/**
 *
 * @description 获取系统配置
 */
export async function queryBSConfig(): Promise<any> {
  return (await Service.http({ url: `/statics/config/web.conf.json?${Date.now()}` })).data;
}

/**
 *
 * @description 获取平台配色
 */
export async function queryDefaultTheme(): Promise<any> {
  return (await Service.http({ url: `/statics/config/theme.info.json?${Date.now()}` })).data;
}

/**
 *
 * @description 获取所有微应用
 */
export async function queryMicroApplicationList(): Promise<any> {
  return (await Service.http({ url: `/statics/config/app.conf.json?${Date.now()}` })).data;
}

index.ts

import React, { useState, useEffect } from 'react';
import { Config } from '@cloud-app-dev/vidc';
import { queryBSConfig, queryMicroApplicationList, queryDefaultTheme } from './utils';

export interface InitialConfigProps {
  children?: React.ReactNode;
  Spin?: React.ReactNode;
  Update?: React.ReactNode;
}

function InitialConfig({ children, Spin, Update }: InitialConfigProps) {
  const [state, setState] = useState({ isInit: false, isUpdate: false });
  useEffect(() => {
    const arr = [queryBSConfig(), queryMicroApplicationList(), queryDefaultTheme()];
    Promise.all(arr).then(([BSConfig, AppConfig, ThemeConfig]) => {
      Config.registerBSConfig(BSConfig);
      Config.registerAppConfig(AppConfig);
      Config.registerThemeConfig(ThemeConfig.content);
      setState(() => ({ isUpdate: BSConfig.isUpdate, isInit: true }));
    });
  }, []);

  return <>{state.isInit ? (state.isUpdate ? Update : children) : Spin}</>;
}

export default InitialConfig;

app.conf.json(定义微应用配置)

{
  "code": 200,
  "data": [
    {
      "id": "10000000",
      "routerPrefix": "login",
      "version": "1.0.0",
      "status": 1,
      "type": 0,
      "name": "登录",
      "icon": "login",
      "resource": ["/micro-apps/login/static/js/login.js", "/micro-apps/login/static/css/login.css"]
    },
    {
      "id": "11000000",
      "routerPrefix": "structApp",
      "version": "1.0.0",
      "status": 1,
      "type": 1,
      "name": "结构化数据资源服务原生应用",
      "icon": "struct-app",
      "resource": ["/micro-apps/struct-app/static/js/struct-app.js", "/micro-apps/struct-app/static/css/struct-app.css"]
    }
  ]
}

********(动态加载指定Script文件):

interface.d.ts

import * as React from 'react';

export interface IOptionsLoader {
  scripts?: string[];
  styles?: string[];
}
export interface ILoaderProps {
  /**
   * @description 脚本加载数据源
   * @default []
   */
  options: IOptionsLoader[];

  /**
   * @description 加载后初始化组件
   * @default null
   */
  children?: React.ReactNode;

  /**
   * @description 加载后初始化组件(loading)
   * @default null
   */
  Spin?: React.ReactNode | null;

  /**
   * @description 加载完成触发事件
   * @default -
   */
  onLoadEnd?: () => Promise<null>;
}

utils.ts

import { IOptionsLoader } from './interface';

const global = window as any;

if (!global._LOADER_SCRIPT_URL) {
  //自定义缓存,用于记录已加载的script
  global._LOADER_SCRIPT_URL = {};
}
//加载js文件方法
export const loadScript = (url: string) => {
  return new Promise((resolve, reject) => {
    //判断是否已加载
    if (global._LOADER_SCRIPT_URL[url]) {
      resolve(null);
    }
    //创建标签
    const script = document.createElement('script');
    script.type = 'text/javascript';
    script.onload = function () {
      //加载后记录当前js加载过了
      global._LOADER_SCRIPT_URL[url] = true;
      resolve(null);
    };
    script.onerror = function (err) {
      reject(err);
    };
    script.src = url;
    //挂载
    document.head.appendChild(script);
  });
};
//加载css文件方法
export const loaderCss = (href: string) => {
  return new Promise((resolve, reject) => {
    if (global._LOADER_SCRIPT_URL[href]) {
      resolve(null);
    }
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = href;
    link.onload = function () {
      global._LOADER_SCRIPT_URL[href] = true;
      resolve(null);
    };
    link.onerror = function (err) {
      reject(err);
    };
    document.head.appendChild(link);
  });
};

export function createAsyncIterable(options: IOptionsLoader[]) {
  return {
    //可以通过设置[Symbol.asyncIterator]属性来自定义异步可迭代对象,可用于for await...of循环 
      ,用于按顺序加载脚本
    [Symbol.asyncIterator]() {
      return {
        i: 0,
        async next() {
          if (this.i < options.length) {
            const param = options[this.i];
            const arr = [];
            if (param.scripts && param.scripts.length > 0) {
              arr.push(...param.scripts.map((v) => loadScript(v)));
            }
            if (param.styles && param.styles.length > 0) {
              arr.push(...param.styles.map((v) => loaderCss(v)));
            }
            if (arr.length > 0) {
              await Promise.all(arr);
              return { value: this.i++, done: false };
            } else {
              return Promise.resolve({ value: this.i++, done: false });
            }
          }
          return Promise.resolve({ value: this.i, done: true });
        },
      };
    },
  };
}

export async function tryCatch(resolveFn: () => Promise<any>, rejectFn: (e: unknown) => void = console.error) {
  try {
    await resolveFn();
  } catch (e) {
    rejectFn(e);
  }
}

export function runAsyncIterable(Iterables: any, runtime?: (conf: any) => void, callback?: () => void) {
  tryCatch(async () => {
    //遍历异步迭代对象,按顺序加载,加载完成后加载下一个
    for await (const x of Iterables) {
      runtime && runtime(x);
    }
    //加载完成触发事件
    callback && Promise.resolve().then(callback);
  });
}

index.ts

import React, { useEffect, useState } from 'react';
import { createAsyncIterable, runAsyncIterable } from './utils';
import { IOptionsLoader,ILoaderProps } from './interface';

function LoaderScripts({ options = [], children, Spin = null, onLoadEnd }: ILoaderProps) {
  const [initStatus, setInitStatus] = useState(false);
  useEffect(() => {
    if (options.length === 0) {
      return setInitStatus(true);
    }
    runAsyncIterable(
      createAsyncIterable(options),
      (option) => console.debug('LoaderScript -> Iterable加载完成!', option),
      () => (onLoadEnd ? onLoadEnd().then(() => setInitStatus(true)) : setInitStatus(true)),
    );
  }, [options]);

  return <>{!initStatus ? Spin : children}</>;
}

export default LoaderScripts;

(微应用加载器,内部动态挂在css,js):

interface.d.ts

export interface AppInstance {
  bootstrap: (props: any) => Promise<any>;
  mount: (props: any) => Promise<any>;
  unmount: (props: any) => Promise<any>;
  update: (props: any) => Promise<any>;
}

index.less

.loaded-app-layout {
  height: 100%;
}

loader.ts

import { AppInstance } from './interface';
import { arrayify, fetchStyle, rewrite } from './utils';

let _global = window as any;

interface createScopedCssText {
  styleNode: HTMLStyleElement;
  prefix: string;
}

export function createScopedCssText({ styleNode, prefix }: createScopedCssText) {
  const sheet = styleNode.sheet;
  const rules = arrayify(sheet?.cssRules ?? []) as CSSRule[];
  return rewrite(rules, prefix);
}

interface ILoaderStyleProps {
  style: string;
  prefix: string;
  styleNode: HTMLStyleElement;
}

export async function LoaderStyle({ style, prefix, styleNode }: ILoaderStyleProps) {
  if (style) {
    /* prettier-ignore */
    const cssText =  await fetchStyle(style);
    const textNode = document.createTextNode(cssText || '');
    styleNode.appendChild(textNode);
    const scopeCssText = createScopedCssText({ prefix, styleNode });
    styleNode.textContent = scopeCssText;
    textNode.remove();
  }
}

interface ILoaderModuleProps {
  script: string;
  style: string;
  prefix: string;
  styleNode: HTMLStyleElement;
}

export default async function LoaderModule({ script, style, prefix, styleNode }: ILoaderModuleProps): Promise<AppInstance> {
  await LoaderStyle({ style, prefix, styleNode });
  return await _global.System.import(script);
}

utils.ts

import { AppItemType } from '../Config/interface';

export const getMicroConfig = (appConfig = {} as AppItemType, appProps = {} as any, container: HTMLDivElement) => {
  const { routerPrefix, resource, name } = appConfig;

  const microAppEntry = { scripts: [resource[0]], styles: [resource[1]], html: `<div id="${routerPrefix}" style="height:100%"></div>` };

  return {
    title: name,
    routerPrefix: routerPrefix,
    container,
    props: appProps,
    entry: microAppEntry,
  };
};

const styleCache = {} as { [key: string]: string };

export async function fetchStyle(style: string) {
  if (styleCache[style]) {
    return styleCache[style];
  }
  const cssText = await fetch(style)
    .then((r) => r.text())
    .catch(() => '');

  styleCache[style] = cssText;
  return cssText;
}

const RuleType = {
  // type: rule will be rewrote
  STYLE: 1,
  MEDIA: 4,
  SUPPORTS: 12,

  // type: value will be kept
  IMPORT: 3,
  FONT_FACE: 5,
  PAGE: 6,
  KEYFRAMES: 7,
  KEYFRAME: 8,
};

export const arrayify = <T>(list: CSSRuleList | any[]) => {
  return [].slice.call(list, 0) as T[];
};

export function rewrite(rules: CSSRule[], prefix = '') {
  let css = '';

  rules.forEach((rule) => {
    switch (rule.type) {
      case RuleType.STYLE:
        css += ruleStyle(rule as CSSStyleRule, prefix);
        break;
      case RuleType.MEDIA:
        css += ruleMedia(rule as CSSMediaRule, prefix);
        break;
      case RuleType.SUPPORTS:
        css += ruleSupport(rule as CSSSupportsRule, prefix);
        break;
      default:
        css += `${rule.cssText}`;
        break;
    }
  });

  return css;
}

// handle case:
// .app-main {}
// html, body {}

// eslint-disable-next-line class-methods-use-this
function ruleStyle(rule: CSSStyleRule, prefix: string) {
  const rootSelectorRE = /((?:[^\w-.#]|^)(body|html|:root))/gm;
  const rootCombinationRE = /(html[^\w{[]+)/gm;

  const selector = rule.selectorText.trim();

  let { cssText } = rule;
  // handle html { ... }
  // handle body { ... }
  // handle :root { ... }
  if (selector === 'html' || selector === 'body' || selector === ':root') {
    return ''; //微应用模式下清楚顶层样式
    // return cssText.replace(rootSelectorRE, prefix);
  }

  // handle html body { ... }
  // handle html > body { ... }
  if (rootCombinationRE.test(rule.selectorText)) {
    const siblingSelectorRE = /(html[^\w{]+)(+|~)/gm;

    // since html + body is a non-standard rule for html
    // transformer will ignore it
    if (!siblingSelectorRE.test(rule.selectorText)) {
      cssText = cssText.replace(rootCombinationRE, '');
    }
  }

  // handle grouping selector, a,span,p,div { ... }
  cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
    selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
      // handle div,body,span { ... }
      if (rootSelectorRE.test(item)) {
        return item.replace(rootSelectorRE, (m) => {
          // do not discard valid previous character, such as body,html or *:not(:root)
          const whitePrevChars = [',', '('];

          if (m && whitePrevChars.includes(m[0])) {
            return `${m[0]}${prefix}`;
          }

          // replace root selector with prefix
          return prefix;
        });
      }

      return `${p}${prefix} ${s.replace(/^ */, '')}`;
    }),
  );

  return cssText;
}

// handle case:
// @media screen and (max-width: 300px) {}
function ruleMedia(rule: CSSMediaRule, prefix: string) {
  const css = rewrite(arrayify(rule.cssRules), prefix);
  return `@media ${rule.conditionText} {${css}}`;
}

// handle case:
// @supports (display: grid) {}
function ruleSupport(rule: CSSSupportsRule, prefix: string) {
  const css = rewrite(arrayify(rule.cssRules), prefix);
  return `@supports ${rule.conditionText} {${css}}`;
}

index.ts

import React, { useRef, useState, useMemo } from 'react';
import { uuid } from '@cloud-app-dev/utils';
import { getMicroConfig } from './utils';
import LoaderModule from './loader';
import { AppInstance } from './interface';
import { AppItemType } from '../Config/interface';
import { useUnmount, useMount } from 'ahooks';
import './index.less';

interface ILoaderAppProps {
  /**
   * @description 微应用配置信息
   * @default -
   */
  appConfig: AppItemType;

  /**
   * @description 需要传递微应用的参数
   * @default {}
   */
  appProps: { [key: string]: any };

  /**
   * @description 微应用加载容器样式
   * @default -
   */
  style?: React.CSSProperties;
}

function LoaderApp({ appConfig, appProps, style }: ILoaderAppProps) {
  const domRef = useRef<HTMLDivElement>(null);
  const styleRef = useRef<HTMLStyleElement>(null);
  const loadedAppRef = useRef<AppInstance>(null);
  const id = useMemo(() => uuid(), []);
  const [status, setStatus] = useState('empty');

  useMount(() => {
    const config = getMicroConfig(appConfig, appProps, domRef.current as HTMLDivElement);
    if (!config) {
      console.error('微应用不存在', 'config ->', appConfig, 'appProps ->', appProps);
      return undefined;
    }

    if (!domRef.current || !styleRef.current) {
      console.error('LoaderApp组件未正常初始化!', 'config ->', appConfig, 'appProps ->', appProps);
      return undefined;
    }

    const props = { ...appProps, container: domRef.current };
    const { routerPrefix } = config;
    const [script, style] = appConfig.resource;
    const options = { 
                        script, 
                        style,
                        name: routerPrefix,
                        prefix: `.${routerPrefix}-${id}`, 
                        styleNode: styleRef.current 
                    };
    LoaderModule(options).then(async (mod: AppInstance) => {
      loadedAppRef.current = mod;
      if (status) {
        await mod.bootstrap(props);
      }
      setStatus('bootstrap');
      await mod.mount(props);
      setStatus('mount');
    });
  });

  useUnmount(() => {
    if (loadedAppRef.current) {
      const app = loadedAppRef.current;
      const props = { ...appProps, container: domRef.current };
      app.unmount(props);
      setStatus('unmount');
      loadedAppRef.current = null as any;
    }
  });

  return (
    <main ref={domRef} className={`loaded-app-layout ${appConfig.routerPrefix}-${id}`} style={style}>
      <style ref={styleRef} />
      <div id={appConfig.routerPrefix} style={{ width: '100%', height: '100%' }}></div>
    </main>
  );
}

LoaderApp.defaultProps = {
  appConfig: {},
  appProps: {},
  style: {},
};

export default LoaderApp;

实际使用:

加载微应用:

import React, { useState } from 'react';
import { LoaderApp } from '@cloud-app-dev/basic-components';
const config = {
  name: 'login',
  script: 'http://192.168.100.42:2235/micro-apps/micro-unified-login/static/js/micro-unified-login.js',
  style: 'http://192.168.100.42:2235/micro-apps/micro-unified-login/static/css/micro-unified-login.css',
  version: '0.0.2',
};
const App = () => {
  return (
    <div>
      <LoaderApp appConfig={config} />
    </div>
  );
};
export default App;

被加载微应用:

import * as ReactDOMClient from 'react-dom/client';
import App from './App';
import { IconFont } from '@cloud-app-dev/vidc';
import './index.less';

IconFont.registerIconFont('/statics/font/icon-font.js');

let root: any;

function render({ container, ...props }: any) {
  const ele = container ? container.querySelector('#structApp') : document.querySelector('#structApp')
  root = ReactDOMClient.createRoot(ele);
  root.render(<App {...props} container={ele} />);
}

if (!window._IS_RUN_MICRO_BASIC) {
  render({ container: document.body });
}

export async function bootstrap() {
  console.debug('structApp app bootstraped');
}

export async function mount(props: any) {
  console.debug('structApp app mount', props);
  render(props);
}

export async function update(props: any) {
  console.debug('structApp app update', props);
  render(props);
}

export async function unmount() {
  root && root.unmount();
}

如何实现微应用通讯?移步至 blog.csdn.net/ujjhuhu/art…