В продолжение моей первой статьи я подготовил реализацию простого проекта, чтобы показать работоспособное решение.
Прежде чем углубляться в детали, я хотел бы отметить, что я решил изменить имя на Scale вместо Pluggable, так как это лучше подчеркивает потенциал этой архитектуры. Идея состоит не только в создании плагинов для приложения, но и в том, чтобы стимулировать разработку самого интерфейса из нескольких источников. И основной особенностью этого должно быть разделение приложения на части, а не создание монолита.
Единственный механизм, который должен находиться в центре системы, — это линия связи между компонентами, логика, которая динамически загружает необходимые сценарии, и процесс склеивания его с определенными правилами. Есть, конечно, много других аспектов, которые вы должны принять во внимание, как:
- Как обмениваться одними и теми же сообщениями о событиях в разных эволюциях системы (например, управление версиями и т. Д.)
- Как обновить библиотеки для всех модулей менее навязчивым способом.
- Как настроить среду разработки одного модуля без необходимости запуска приложения-монстра.
- Как настроить инфраструктуру для тестов E2E и сохранить эти тесты в репозитории модуля.
- И многое другое
Прежде чем углубиться во все эти проблемы, в этой статье я познакомлю вас с первой реализацией простого приложения . В настоящее время код для этой статьи находится в мастерской. Как только я отполирую его и начну работать над более продвинутой частью, я перенесу его в ветку часть-1 .
Чтобы настроить проект локально, вам нужно иметь последнюю версию Node.js и глобально установить yarn and gulp.
Вам также может понравиться:
Основы Redux .
Как только это будет сделано, запуска gulp
достаточно для настройки всей инфраструктуры проекта. Чтобы упростить его настройку и демонстрацию из одного места, я поместил три модуля в один репозиторий GitHub. Хотя в сценарии из реальной жизни они будут разбиты на три отдельных хранилища.
Пример, который я создал, относится к Системе управления пользователями, где возможно:
- Смотрите домашнюю страницу с некоторой базовой информацией о сайте (Главная)
Главная страница
- Просмотр пользователей (Пользователь)
Страница пользователя
- Добавить новых пользователей (Admin)
Страница администратора
- Предоставить / отозвать разрешения, позволяющие отдельным пользователям удалять пользователей или скрывать / показывать страницу администратора (Настройки)
- Когда разрешение «разрешить удаление пользователей» включено, на странице пользователя появляется значок корзины.
- Когда «разрешить просмотр страницы администратора» отключено, навигация по «Администратору» исчезает.
Установка прав администратора
Страницы «Домой» и «Пользователь» объединяются с основным приложением, а две другие загружаются во время загрузки системы.
Во-первых, нам нужно сконфигурировать наш бэкэнд для чтения метаданных о наших основных модулях и предоставления API для загрузки. В этом проекте мы создали папку “modules”
для этого . Здесь скомпилированные модули, готовые к загрузке, распространяются.
Единственный файл, созданный вручную modules-metadata.json
. Опять же, это сделано для простоты. В реальном сценарии он будет внутри каждого модуля, как modules/admin/metadata.json
с фрагментом
JSON
1
{
2
"name": "admin",
3
"entry": "/modules/admin/index.js",
4
"options": {
5
"tab": {
6
"title": "Admin"
7
}
8
}
9
}
И modules/setting/metadata.json
с фрагментом:
JSON
x
1
{
2
"name": "settings",
3
"entry": "/modules/settings/index.js",
4
"options": {
5
"tab": {
6
"title": "Settings"
7
}
8
}
9
}
В Express, чтобы сделать эту папку легко доступной из нашего интерфейса, нам нужно, чтобы эта папка служила статической папкой.
JavaScript
x
1
gulp.task('server:start', (cb) => {
2
app.use(express.json());
3
app.get('/', (req, res) => {
4
res.sendFile(`${paths.distDir}/index.html`);
5
});
6
registerDatabaseApi();
7
app.use(express.static(`${paths.projectDir}/dist`));
8
app.use('/modules', express.static(`${paths.projectDir}/modules`)); // that happens here
9
app.listen(serverPort, () => {
10
log.info(`DSFA is started on port ${serverPort}!`);
11
cb();
12
});
13
});
На внешнем интерфейсе мы должны знать, что этот файл должен быть там, и запрашивать его api-resource.js
.
JavaScript
xxxxxxxxxx
1
export const getModulesMetadata = () => httpRequest('GET', 'modules/modules-metadata.json');
Этот вызов выполняется из redux-saga , поэтому, как только данные будут получены, они будут сохранены в избыточном хранилище.
JavaScript
xxxxxxxxxx
1
export function* loadCustomModulesSaga() {
2
try {
3
const {data: {modules = []}} = yield call(getModulesMetadata);
4
yield putResolve(customModulesActions.set(modules));
5
} catch (exception) {
6
yield put(toastrActions.show('Error', exception, TOASTR_TYPE.error));
7
}
8
}
Основной компонент приложения прослушивает изменения, и когда он находит изменения в модулях, он реагирует на них ( app.js
).
JavaScript
xxxxxxxxxx
1
const mapStateToProps = (state) => ({
2
bootstrappedModules: state.bootstrap.bootstrappedModules,
3
permissions: state.permissions,
4
modules: state.customModules, // here
5
users: state.users
6
});
7
@connect(mapStateToProps)
9
export class App extends Component {
Есть два места, которые адаптированы к изменениям модуля:
HTML
xxxxxxxxxx
1
<CustomLinks modules={modules} restrictedModuleNames={restrictedModuleNames}></div>
Эта часть добавляет больше навигационных компонентов, по которым пользователь может щелкнуть и перейти туда.
JavaScript
xxxxxxxxxx
1
<CustomRoutes
2
bootstrappedModules={bootstrappedModules}
3
modules={modules}
4
restrictedModuleNames={restrictedModuleNames}
5
></div>
Этот компонент фактически загружает плагин через вызов API, присоединяет его к странице HTML, извлекает все источники из этого пакета и соединяет его с соответствующими частями основного модуля. CustomRoutes
использует LoadModule
компонент для выполнения этих действий.
Давайте посмотрим на этот процесс по частям:
Сначала загружаем скрипт и ждем пока он не смонтирован на странице
JavaScript
xxxxxxxxxx
1
useEffect(() => {
2
if (R.not(R.includes(moduleName, bootstrappedModules))) {
3
const script = document.createElement('script’);
4
script.src = scriptUrl; // that’s a backend URL by which the JS bundle of custom module is accessible.
5
script.async = true; // no need to do it synchronously.
6
script.onload = () => {
7
setLoadedModuleName(moduleName); // once module is loaded we can proceed with connecting it to the core module
8
};
9
document.body.appendChild(script);
10
} else {
11
setLoadedModuleName(moduleName);
12
}
13
return () => {
15
if (context.saga) { // once component is unmounted, we also cancel all running sagas.
16
context.saga.cancel();
17
}
18
};
19
}, []);
Затем мы получаем код из смонтированного комплекта и включаем его в основной модуль
JavaScript
xxxxxxxxxx
1
if (R.complement(R.isNil)(loadedModuleName)) {
2
const {
3
component: Component,
4
reducers,
5
saga
6
} = window[loadedModuleName].default;
7
if (R.not(R.includes(loadedModuleName, bootstrappedModules))) {
9
if (saga) { // it makes not compulsory for module to expose sagas, if it doesn’t have it
10
context.saga = window.dsfaSaga.run(saga); // window.dsfaSaga - is a reference to a joint middleware created in core module, so then we can run sagas brought by this custom module.
11
}
12
if (reducers) { // // it makes not compulsory for module/plugin to expose reducers, if it doesn’t have it
13
window.dsfaReducerRegistry.register(reducers); // window.dsfaReducerRegistry - is a joint register, created by a core module, and is used to register/unregister reducers from the entire system. Here we add new reducers brought by this custom module.
14
}
15
dispatch(applicationActions.moduleBootstrapped(loadedModuleName)); // marks that current module is loaded and prevents of loading it once again.
16
}
17
return <Component></div>;
18
}
Чтобы понять, откуда window.dsfaReducerRegistry
и window.dsfaSaga
откуда , взгляните на configure-store.js
файл:
JavaScript
xxxxxxxxxx
1
const sagaMiddleware = createSagaMiddleware();
2
window.dsfaSaga = sagaMiddleware;
3
export const reducerRegistry = new ReducerRegistry(allReducers);
5
window.dsfaReducerRegistry = reducerRegistry;
Для того, чтобы можно было регистрировать редукторы из разных частей приложения, создается небольшая обертка, поскольку вы заметили, что ReducerRegister
:
JavaScript
xxxxxxxxxx
1
export class ReducerRegistry {
2
constructor(initialReducers = {}) {
3
this.reducers = {initialReducers};
4
this.emitChange = null;
5
}
6
register(newReducers) {
8
this.reducers = {this.reducers, newReducers};
9
if (this.emitChange !== null) {
10
this.emitChange(this.getReducers());
11
}
12
}
13
getReducers() {
15
return {this.reducers};
16
}
17
setChangeListener(listener) {
19
if (this.emitChange !== null) {
20
throw new Error('Can only set the listener for a ReducerRegistry once.');
21
}
22
this.emitChange = listener;
23
}
24
}
И когда магазин создан, вам нужно создать редуктор:
JavaScript
xxxxxxxxxx
1
export const configureStore = (history) => {
2
const mainReducer = configureReducers(reducerRegistry.getReducers());
3
const store = createStoreWithMiddleware(history)(mainReducer);
4
reducerRegistry.setChangeListener((reducers) => {
6
store.replaceReducer(configureReducers(reducers));
7
});
8
window.dsfaStore = store;
9
return store;
10
};
По мере появления новых редукторов их заменяют в магазине. И то же самое происходит здесь, когда вы делитесь хранилищем Redux через окно с пользовательскими модулями. Зачем использовать окно? Потому что так можно создать общие объекты между различными модулями, скомпилированными индивидуально с помощью веб-пакета.
Давайте посмотрим на эту конфигурацию, которую необходимо выполнить в пользовательском модуле webpack.config.plugin.common.js
:
JavaScript
xxxxxxxxxx
1
output: {
2
filename: 'index.js',
3
globalObject: 'window’, // That’s the way how to make some points of the system shared between bundles.
4
library: pluginName,
5
libraryTarget: 'umd’, // ! globalObject will work only if target is udm
6
path: `${paths.modulesDistDir}/${moduleName}`,
7
publicPath: '/'
8
},
9
А также внешние, чтобы не дублировать сторонние библиотеки:
xxxxxxxxxx
1
const createItem = (key, globalName) => ({
2
[key]: {
3
amd: key,
4
commonjs: key,
5
commonjs2: key,
6
root: globalName
7
}
8
});
9
export const externals = {
11
createItem('react', 'React'),
12
createItem('react-dom', 'ReactDOM')
13
};
Это скребет связку React и response-dom из пользовательского плагина. React не жалуется на две свои версии в приложении (и в этом случае аварийно завершает работу с response-redux ) и делает плагин действительно небольшим по сравнению с полностью упакованной версией, предоставляемой в случае использования iframe. Список этих внешних объектов будет увеличиваться с увеличением количества библиотек, которые вы будете использовать.
На стороне основного модуля вам необходимо зарегистрировать такие модули глобально globals.js
.
JavaScript
xxxxxxxxxx
1
import React from 'react';
2
import ReactDOM from 'react-dom';
3
window.React = React;
5
window.ReactDOM = ReactDOM;
Если в некоторых случаях плагин не может быть успешно загружен, чтобы не разбить все приложение, мы должны изолировать его, перехватить исключение и показать пользователю информацию о нем. Для этого CatchError
компонент создан.
JavaScript
xxxxxxxxxx
1
export default class CatchError extends PureComponent {
2
static propTypes = {children: PropTypes.element};
3
constructor(props) {
5
super(props);
6
this.state = {hasError: false};
7
}
8
componentDidCatch() { // This part does the logic to keep the application alive.
10
this.setState({hasError: true});
11
}
12
render() {
14
if (this.state.hasError) {
15
return <h1>Plugin has not been loaded successfully due to found errors</h1>;
16
}
17
return this.props.children;
18
}
19
}
Это, по сути, все основные части приложения, описанные выше. Остальная часть кода должна сделать приложение более или менее привлекательным с минимальной функциональностью. Я рекомендую вам скачать рабочий сценарий; установка очень проста. Поиграй с этим! Добавьте какую-то цель, улучшите ее с помощью небольшой функции и почувствуйте, как идет разработка для вас. Например, предположим, что вы разработчик, которому предлагается добавить модуль «история» с собственной вкладкой и показать все выполненные действия (добавление / удаление пользователей).
Легкий уровень
С текущей данной реализацией, какой минимум вы будете изменять в модуле ядра, и что вы добавите в модуль истории? Как вы собираетесь общаться эффективно?
Средний уровень
Как основной плагин должен быть переписан, чтобы новая история или любые другие потенциальные функциональные возможности не требовали изменений в основном модуле? Так как это конечная цель этой архитектуры.
Мне любопытно и взволнован, чтобы увидеть ваши вилки / PR / умные и гениальные идеи по этому поводу. Ждем отзывов и комментариев!