ant-design / ant-design-mobile

Essential UI blocks for building mobile web apps.
https://mobile.ant.design
MIT License
11.66k stars 2.4k forks source link

服务端渲染报错 #4255

Closed childrentime closed 3 years ago

childrentime commented 3 years ago

版本信息:

os: win10 node: v14.17.5 antd-mobile: ^5.0.0-beta.15 @loadable/component: ^5.15.0 @loadable/server: ^5.15.1

src/app.tsx

import { Switch, Route } from 'react-router-dom';
import loadable from '@loadable/component';
import './main.css';

const Index = loadable(() => import('./pages/index'));
const Announcement = loadable(() => import('./pages/announcement'));
const Me = loadable(() => import('./pages/me'));
const Faq = loadable(() => import('./pages/faq'));
const Mainsite = loadable(() => import('./pages/mainsite'));
const AnswerEditor = loadable(() => import('./components/answer/editor'));
const Search = loadable(() => import('./pages/search'));
const SearchResult = loadable(() => import('./pages/searchResult'));
const QuestionEditor = loadable(() => import('./components/question/editor'));

function App() {
    return (
        <Switch>
            <Route exact path="/" component={Index} />
            <Route path="/announcement" component={Announcement} />
            <Route path="/user" component={Me} />
            <Route path="/faq/:questionId" component={Faq} />
            <Route path="/mainsite" component={Mainsite} />
            <Route
                path="/replyEditor/:parentId/:followId?"
                component={AnswerEditor}
            />
            <Route path="/answerEditor/:questionId" component={AnswerEditor} />
            <Route path="/questionEditor" component={QuestionEditor} />
            <Route path="/search" component={Search} />
            <Route path="/searchResult/:query" component={SearchResult} />
        </Switch>
    );
}
export default App;

src/index.tsx

import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import App from './app';
import { loadableReady } from '@loadable/component';

loadableReady(() =>
    ReactDOM.hydrate(
        <Router>
            <App />
        </Router>,
        document.getElementById('root')
    )
).then(() => console.log('loaded'));

server/index.ts

import path from 'path';
import Koa from 'koa';
import router from './route';
import serve from 'koa-static';
import proxy from 'koa2-proxy-middleware';

const app = new Koa();

/*静态资源访问 访问client打包之后的js文件*/
const main = serve(path.resolve('dist'));
app.use(main);

// 后台请求转发
const options = {
    targets: {
        '/api/(.*)': {
            target: 'http://forum-api.kdocs.cn/',
            changeOrigin: true
        }
    }
};
app.use(proxy(options));
app.use(router.routes());

app.listen(3002, () => {
    console.log('服务端渲染  run in port 3002');
});

server/route.tsx

import { Context } from 'koa';
import Router from 'koa-router';
import React from 'react';
import { StaticRouter } from 'react-router-dom';
import { renderToString } from 'react-dom/server';
import App from '../src/App';
import { ChunkExtractor } from '@loadable/server';
import path from 'path';

import { enableStaticRendering } from 'mobx-react-lite';
enableStaticRendering(true);
const router = new Router();

//对于所有的路由 我们都返回这个
//只有手动刷新的时候 才会执行这个方法
//否则是 客户端的 broswerRouter接管路由
router.get('(.*)', async (ctx: Context) => {
    console.log(ctx.request.url);
    // This is the stats file generated by webpack loadable plugin
    const statsFile = path.resolve('dist/loadable-stats.json');
    // We create an extractor from the statsFile
    const extractor = new ChunkExtractor({ statsFile, entrypoints: ['index'] });
    // Wrap your application using "collectChunks"
    const jsx = extractor.collectChunks(
        <StaticRouter location={ctx.request.url}>
            <App />
        </StaticRouter>
    );
    // Render your application
    const html = renderToString(jsx);
    // You can now collect your script tags
    const scriptTags = extractor.getScriptTags(); // or extractor.getScriptElements();
    // You can also collect your "preload/prefetch" links
    const linkTags = extractor.getLinkTags();
    ctx.response.body = `
    <!doctype html>
    <html lang="en">
        <head> ${linkTags}</head>
        <body>
        <div id="root">${html}</div>
        ${scriptTags}
    </html>
    `;
});
export default router;

webpack.client.ts

import { CleanWebpackPlugin } from 'clean-webpack-plugin';
import path from 'path';
import HtmlWebpackPlugin from 'html-webpack-plugin';
const LoadablePlugin = require('@loadable/webpack-plugin');

//客户端打包
module.exports = {
    mode: 'production',
    entry: { index: path.join(__dirname, '../src/index.tsx') },
    output: {
        filename: '[name].[contenthash].js',
        publicPath: '/',
        path: path.resolve(__dirname, '../dist')
    },
    resolve: {
        alias: {
            '@': path.resolve(__dirname, '../src')
        },
        extensions: ['.ts', '.tsx', '.js', '.json']
    },
    module: {
        rules: [
            //babel 配置
            {
                test: /\.tsx?$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: [
                            '@babel/preset-env',
                            [
                                '@babel/preset-react',
                                {
                                    runtime: 'automatic'
                                }
                            ],
                            '@babel/preset-typescript'
                        ],
                        plugins: [
                            [
                                '@babel/plugin-transform-runtime',
                                {
                                    corejs: 3
                                }
                            ],
                            ['@babel/plugin-syntax-dynamic-import'],
                            ['@loadable/babel-plugin']
                        ]
                    }
                },
                exclude: /node_modules/ //排除 node_modules 目录
            },
            //图片
            {
                test: /\.(png|jpg|gif|svg|svg+xml)$/i,
                type: 'asset/inline'
            },
            {
                test: /\.(woff|woff2|eot|ttf|otf)$/i,
                type: 'asset/inline'
            },
            //css配置
            {
                test: /\.css$/i,
                use: ['style-loader', 'css-loader']
            },
            //less
            {
                test: /\.less$/i,
                use: [
                    {
                        loader: 'style-loader'
                    },
                    {
                        loader: 'css-loader'
                    },
                    {
                        loader: 'less-loader',
                        options: {
                            lessOptions: {
                                strictMath: true
                            }
                        }
                    }
                ]
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new LoadablePlugin(),
        new HtmlWebpackPlugin({
            template: 'public/index.html',
            filename: path.resolve('index.html'),
            chunk: ['index']
        })
    ]
};

webpack.server.ts

import { CleanWebpackPlugin } from 'clean-webpack-plugin';
import path from 'path';
import nodeExternals from 'webpack-node-externals';
const noRequireCss = require('./purge-css');

module.exports = {
    mode: 'production',
    entry: { index: path.join(__dirname, '../server/index.ts') },
    externalsPresets: { node: true }, //相当于 target:node
    externals: [nodeExternals()],
    output: {
        filename: 'bundle.js',
        publicPath: '',
        path: path.resolve(__dirname, '../target'),
        globalObject: `typeof self !== 'undefined' ? self : this`
    },
    resolve: {
        alias: {
            '@': path.resolve(__dirname, '../src')
        },
        extensions: ['.ts', '.tsx', '.js', '.json']
    },
    module: {
        rules: [
            //babel 配置
            {
                test: /\.tsx?$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: [
                            '@babel/preset-env',
                            [
                                '@babel/preset-react',
                                {
                                    runtime: 'automatic'
                                }
                            ],
                            '@babel/preset-typescript'
                        ],
                        plugins: [
                            [
                                '@babel/plugin-transform-runtime',
                                {
                                    corejs: 3
                                }
                            ],
                            [noRequireCss],
                            ['dynamic-import-node'],
                            ['@loadable/babel-plugin']
                        ]
                    }
                },
                exclude: /node_modules/ //排除 node_modules 目录
            }
        ]
    },
    plugins: [new CleanWebpackPlugin()]
};

noRequireCss

module.exports = function () {
    return {
        name: 'no-require-css',
        visitor: {
            ImportDeclaration(path: any, state: any) {
                let importFile = path.node.source.value;
                if (importFile.indexOf('.css') > -1) {
                    path.remove();
                }
                if (importFile.indexOf('.less') > -1) {
                    path.remove();
                }
                if (importFile.indexOf('.png') > -1) {
                    path.remove();
                }
            }
        }
    };
};

运行yarn run client yarn run serve node target/bundle.js报错

服务端渲染  run in port 3002
/
loadable-components: failed to synchronously load component, which expected to be available {
  fileName: 8050,
  chunkName: 'pages-index',
  error: "Unexpected token ':'"
}

  C:\Users\wps\Desktop\Jingying\forum\node_modules\antd-mobile\cjs\global\global.css:1
  :root {
  ^

  SyntaxError: Unexpected token ':'
      at wrapSafe (internal/modules/cjs/loader.js:988:16)
      at Module._compile (internal/modules/cjs/loader.js:1036:27)
      at Object.Module._extensions..js (internal/modules/cjs/loader.js:1101:10)       
      at Module.load (internal/modules/cjs/loader.js:937:32)
      at Function.Module._load (internal/modules/cjs/loader.js:778:12)
      at Module.require (internal/modules/cjs/loader.js:961:19)
      at require (internal/modules/cjs/helpers.js:92:18)
      at Object.<anonymous> (C:\Users\wps\Desktop\Jingying\forum\node_modules\antd-mobile\cjs\global\index.js:3:1)
      at Module._compile (internal/modules/cjs/loader.js:1072:14)
      at Object.Module._extensions..js (internal/modules/cjs/loader.js:1101:10)
childrentime commented 3 years ago

我尝试修改了purgecss的代码

module.exports = function () {
    return {
        name: 'no-require-css',
        visitor: {
            ImportDeclaration(path: any, state: any) {
                let importFile = path.node.source.value;
                console.log(importFile);
                if (importFile.indexOf('.css') > -1) {
                    path.remove();
                }
                if (importFile.indexOf('.less') > -1) {
                    path.remove();
                }
                if (importFile.indexOf('.png') > -1) {
                    path.remove();
                }
               //新增的
                if (importFile === 'antd-mobile') {
                    path.remove();
                }
            }
        }
    };
};

ok 我可以成功访问到某些页面了 但是当我直接访问某个包含antd-mobile组件的页面的时候 它会报错 ReferenceError: InfiniteScroll is not defined 所以我需要手动地去判断当前的环境是document还是node 有没有更好的方法呢

awmleer commented 3 years ago

看起来是 InfiniteScroll 组件的引用没有找到,可以贴一下你的 babel 配置么?

childrentime commented 3 years ago

@awmleer babel就配置在webpack中 没有.babelrc.js文件

awmleer commented 3 years ago

奇怪,看起来 babel 配置是没有问题的。 那你是怎么引入的 InfiniteScroll 这个组件的呢?

childrentime commented 3 years ago

类似于这样 我现在是做了一个环境的判断

import { InfiniteScroll } from 'antd-mobile';
import produce from 'immer';
import { useState } from 'react';
import siteObj from '@/store/site';
import {
    annoucementList,
    AnnouncementList,
    AnnouncementListParams
} from '@/api/announcement';
import './style.less';

const prefix = 'announcement';
export default function Announcement() {
    const [hasMore, setHasMore] = useState(true);
    const [offset, setOffset] = useState(0);
    const [announcement, setAnnouncement] = useState<AnnouncementList>({
        rows: [],
        total: 0
    });

    const loadMore = async () => {
        const data: AnnouncementListParams = {
            site: siteObj.site,
            limit: 5,
            offset
        };
        const res = await annoucementList(data);
        console.log(res.data);
        const copy = produce(announcement, draft => {
            draft.rows = draft.rows.concat(res.data.rows);
            draft.total = res.data.total;
        });
        setHasMore(copy.rows.length !== copy.total);
        setOffset(copy.rows.length);
        setAnnouncement(copy);
    };

    if (typeof window !== 'undefined') {
        return (
            <div className={prefix}>
                {announcement.rows.map(item => <RenderAnnouncement key={item._id} data={item} />)}
                <InfiniteScroll loadMore={loadMore} hasMore={hasMore} />
            </div>
        );
        // 服务端渲染的时候 我们应该返回的静态HTML
    } 
    return <></>;

}

const RenderAnnouncement = (props: { data: AnnouncementList['rows'][0] }) => {
    const { data } = props;
    return (
        <div
            className={`${prefix}-container`}
            onClick={() => {
                window.open(data.link);
            }}
        >
            <div className={`${prefix}-container-top`}>{data.title}</div>
            <div className={`${prefix}-container-bottom`}>
                <span>14分钟前</span>
                <span>{data.user.nickname}</span>
            </div>
        </div>
    );
};
zyw00001 commented 3 years ago

你这样相当于没有SSR了

zyw00001 commented 3 years ago

你这个问题和我遇到时一个问题,本质上还是antd-mobile里强制导入了CSS,导致Nodejs没法直接运行antd-mobile库里的代码。antd-mobile不能作为一个纯粹的外部库了,externals: [nodeExternals()]这需要将antd-mobile排除掉。

childrentime commented 3 years ago

我排除了antd-mobile 并且增加了服务端处理css webpack.server.ts

import { CleanWebpackPlugin } from 'clean-webpack-plugin';
import path from 'path';
import nodeExternals from 'webpack-node-externals';
const noRequireCss = require('./purge-css');

//服务端打包
module.exports = {
    mode: 'production',
    entry: { index: path.join(__dirname, '../server/index.ts') },
    externalsPresets: { node: true }, //相当于 target:node
    externals: [
        //新增
        nodeExternals({
            allowlist: ['antd-mobile']
        })
    ],
    output: {
        filename: 'bundle.js',
        publicPath: '',
        path: path.resolve(__dirname, '../target'),
        globalObject: `typeof self !== 'undefined' ? self : this`
    },
    resolve: {
        alias: {
            '@': path.resolve(__dirname, '../src')
        },
        extensions: ['.ts', '.tsx', '.js', '.json']
    },
    module: {
        rules: [
            //babel 配置
            {
                test: /\.tsx?$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: [
                            '@babel/preset-env',
                            [
                                '@babel/preset-react',
                                {
                                    runtime: 'automatic'
                                }
                            ],
                            '@babel/preset-typescript'
                        ],
                        plugins: [
                            [
                                '@babel/plugin-transform-runtime',
                                {
                                    corejs: 3
                                }
                            ],
                            [noRequireCss],
                            ['dynamic-import-node'],
                            ['@loadable/babel-plugin']
                        ]
                    }
                },
                exclude: /node_modules/ //排除 node_modules 目录
            },
            //新增
            //图片
            {
                test: /\.(png|jpg|gif|svg|svg+xml)$/i,
                type: 'asset'
            },
            {
                test: /\.(woff|woff2|eot|ttf|otf)$/i,
                type: 'asset'
            },
            {
                test: /\.css$/i,
                use: ['style-loader', 'css-loader', 'postcss-loader']
            },
            //less
            {
                test: /\.less$/i,
                use: [
                    'style-loader',
                    'css-loader',
                    'postcss-loader',
                    {
                        loader: 'less-loader',
                        options: {
                            lessOptions: {
                                strictMath: true
                            }
                        }
                    }
                ]
            }
        ]
    },
    plugins: [new CleanWebpackPlugin()]
};

同时我取消了对于node环境的判断 announcement.tsx

import { InfiniteScroll } from 'antd-mobile';
import produce from 'immer';
import { useState } from 'react';
import siteObj from '@/store/site';
import {
    annoucementList,
    AnnouncementList,
    AnnouncementListParams
} from '@/api/announcement';
import './style.less';

const prefix = 'announcement';
export default function Announcement() {
    const [hasMore, setHasMore] = useState(true);
    const [offset, setOffset] = useState(0);
    const [announcement, setAnnouncement] = useState<AnnouncementList>({
        rows: [],
        total: 0
    });

    const loadMore = async () => {
        const data: AnnouncementListParams = {
            site: siteObj.site,
            limit: 5,
            offset
        };
        const res = await annoucementList(data);
        console.log(res.data);
        const copy = produce(announcement, draft => {
            draft.rows = draft.rows.concat(res.data.rows);
            draft.total = res.data.total;
        });
        setHasMore(copy.rows.length !== copy.total);
        setOffset(copy.rows.length);
        setAnnouncement(copy);
    };

    return (
        <div className={prefix}>
            <div>一些我应该能看到的文字</div>
            {announcement.rows.map(item => (
                <RenderAnnouncement key={item._id} data={item} />
            ))}
            <InfiniteScroll loadMore={loadMore} hasMore={hasMore} />
        </div>
    );
    // 服务端渲染的时候 我们应该返回的静态HTML
}

如果我将

  {announcement.rows.map(item => (
                <RenderAnnouncement key={item._id} data={item} />
            ))}
            <InfiniteScroll loadMore={loadMore} hasMore={hasMore} />

注释掉 那么首屏进入可以正常访问 并且查看源代码可以看到

一些我应该能看到的文字
否则 还是会出现 ReferenceError: document is not defined at Object.e.exports [as insertStyleElement] 的错误

childrentime commented 3 years ago

我注释掉了mobx的引入 确保不是mobx的问题

zyw00001 commented 3 years ago

不能用style-loader,要用isomorphic-style-loader

childrentime commented 3 years ago

@zyw00001 Ok, Thanks a lot! 它终于工作正常了 我总结一下: 问题的关键在于antd-mobile进行服务端渲染的时候 需要使用webpack-node-externals 排除 并且引入isomorphic-style-loader将它的样式插入到html中

childrentime commented 3 years ago

看起来这应该与antd-mobile的打包方式相关 使用antd的时候没有此问题 因为antd需要引入一个css文件