(四)React服务端渲染与同构

一、简介

参考资料:

  1. egg官网文档
  2. npm-run-all
  3. React服务端渲染与同构实践
  4. React 中同构(SSR)原理脉络梳理
  5. React 同构实践
  6. koa状态码
  7. StaticRouter 重定向

背景

  • 第一阶段

很久以前, 一个网站的开发还是前端和服务端在一个项目来维护, 可能是用php+jquery。那时候的页面渲染是放在服务端的, 也就是用户访问一个页面a的时候, 会直接访问服务端路由, 由服务端来渲染页面然后返回给浏览器。也就是说网页的所有内容都会一次性被写在html里, 一起送给浏览器。这时候你右键点击查看网页源代码, 可以看到所有的代码; 或者你去查看html请求, 查看”预览”, 会发现他就是一个完整的网页。

  • 第二阶段

但是慢慢的人们觉得上面这种方式前后端协同太麻烦, 耦合太严重, 严重影响开发效率和体验。于是随着vue/react的横空出世, 人们开始习惯了纯客户端渲染的spa.这时候的html中只会写入一些主脚本文件, 没有什么实质性的内容. 等到html在浏览器端解析后, 执行js文件, 才逐步把元素创建在dom上。所以你去查看网页源代码的时候, 发现根本没什么内容, 只有各种脚本的链接。

  • 第三阶段

后来人们又慢慢的觉得, 纯spa对SEO非常不友好, 并且白屏时间很长。对于一些活动页, 白屏时间长代表了什么? 代表了用户根本没有耐心去等待页面加载完成.所以人们又想回到服务端渲染, 提高SEO的效果, 尽量缩短白屏时间.所以现在有了新的方案, 新的模式, 叫做同构。
所谓的同构理解为:同种结构的不同表现形态, 同一份react代码, 分别在两端各执行一遍。

1、csr与ssr

  • SSR:在服务端,把页面所有的内容都初始化好后,将整个页面的HTML丢到浏览器,给浏览器渲染,浏览器渲染的是整个页面的内容,页面的任何操作都会引起页面的刷新,服务端重新生成页面然后返回整个html文件给客户端
    • 优点
      • 更好的首屏性能,不需要提前下载一堆css和js后才可看到页面
      • 更利于SEO,蜘蛛可以直接抓取已渲染的内容
    • 缺点
      • 每次页面的改变都需要服务端把所有的代码都初始化,页面需刷新后重新展示
  • CSR:服务器只返回必要的html(只有一个框架和占位节点)和js资源,js在客户端解析、执行然后渲染生成页面,同时js执行会完成页面事件的绑定,优点是前后端分离,页面交互较好
  • SSR + SPA:服务端渲染返回首屏,客户端渲染进行aqs88saa交互。
    • 既能满足首屏的性能和SEO,又提高了后续页面操作性能和体验
    • 前后端分离开发:前端注重动画等渲染,后端注重数据及性能
1、renderToString
  • spa react-dom -> render
  • ssr react-dom/server -> renderToString

spa里一定见过react-dom的render方法, 其实react-dom里还有一个renderToString方法。

  • server.js
// server.js

const express = require('express');
const app = express();
const React = require('react');
const {renderToString} = require('react-dom/server');
const PORT = 3000;

const App = class extends React.PureComponent{
    render(){
        return React.createElement("h1",null,"Hello World
        ");;
    }
};

app.get('/',function(req,res){
    const content = renderToString(React.createElement(App));
    res.send(content);
});

app.listen(PORT, () => {
    console.log(`sever listen on ${PORT}`)
});

复制代码
  • webpack 配置
// build/webpack-server.config.js

const path = require('path');
const nodeExternals = require('webpack-node-externals');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
    entry:{
        index:path.resolve(__dirname,'../server.js')
    },
    mode:'development',
    target:'node',
    devtool: 'cheap-module-eval-source-map',
    output:{
        filename:'[name].js',
        path:path.resolve(__dirname,'../dist/server')
    },
    externals:[nodeExternals()],
    resolve:{
        alias:{
            '@':path.resolve(__dirname,'../src')
        },
        extensions:['.js']
    },

    module:{
        rules:[{
            test:/\.js$/,
            use:'babel-loader',
            exclude:/node_modules/
        }]
    },

    plugins: [
        new CopyWebpackPlugin([{
            from:path.resolve(__dirname,'../public'),
            to:path.resolve(__dirname,'../dist')
        }])
    ]
}

复制代码
  • 打包脚本配置
// package.json添加打包的脚本
"build:server": "webpack --config build/webpack-server.config.js --watch"
// package.json添加运行server.js的脚本
"server": "nodemon dist/server/index.js"

//运行一下
npm run build:server
npm run server
复制代码
  • 给h1标签绑定一个click事件
import React from 'react';
import {renderToString} from 'react-dom/server';
const express = require('express');
const app = express();

const App = class extends React.PureComponent{
    handleClick=(e)=>{
        alert(e.target.innerHTML);
    }

    render(){
        return <h1 onClick={this.handleClick}>Hello World!</h1>;
    }
};

app.get('/',function(req,res){
    const content = renderToString(<App/>);
    console.log(content);  //获取到完整的html 打印出<h1>Hello World!</h1>
    res.send(content);
});

app.listen(3000);

复制代码

上面一些列代码结束,会遇到一个问题,发现怎么点击都没有反应? renderToString是把元素转成字符串而已, 事件什么的根本没有绑定;所以接下来需要用到同构。

2、同构

一般是指服务端和客户端同构,意思是服务端和客户端运行同一套代码程序

  • 服务端执行:让 React 代码在服务器端先执行一次,使得用户下载的 HTML 已经包含了所有的页面展示内容
  • 客户端执行:React代码在客户端再次执行,为 HTML 网页中的内容添加数据及事件的绑定,页面就具备了 React 的各种交互能力

react-ssr.png

3、代码解析
  • ReactDOMServer.renderToString(element)

    服务端渲染,将React元素渲染为初始HTML,并在首次请求时将标记下发,以加快页面加载速度,并允许搜索引擎爬取你的页面以达到 SEO 优化的目的。

  • ReactDOM.hydrate(element, container[, callback])

    与render()相同,但它用于在ReactDOMServer渲染的容器中对HTML的内容进行hydrate操作,react会尝试在已有标记上绑定事件监听器。

  • ReactDOM.render(element, container[, callback])

    在提供的container里渲染一个React元素,并返回对该组件的引用(或者对无状态组件返回null),但避免使用该引用(ref替代)

    • 如果React元素之前在container里渲染过,这将会对其执行更新操作,并仅会在必要时改变DOM以映射最新的React元素,会使用React的DOM差分算法进行高效的更新。
    • 如果提供了可选的回调函数,该回调将在组件被渲染或更新之后被执行。
// 代码结构
/*
 * -- build
 *  |-- webpack-client.config.js
 *  |-- webpack-server.config.js
 * -- server.js
 * -- src
 *  |-- app.js
 *  |-- index.js
 *  |-- pages
 *    |-- login
 *    |-- user
 *    |-- notFound
 *  |-- router
 *    |-- index.js
 *    |-- routerConfig.js
 */


// 1、src/app.js

import React from 'react';
class App extends React.PureComponent{
    handleClick=(e)=>{
        alert(e.target.innerHTML);
    }
    render(){
        return <h1 onClick={this.handleClick}>Hello World!</h1>;
    }
};
export default App;


// 2、src/index.js
// 就跟正常spa应用一样的写法

import React from 'react';
import {render} from 'react-dom';
import App from './app';
render(<App/>,document.getElementById("root"));


// 3、build/webpack-client.config.js
// 处理客户端代码的打包逻辑

const path = require('path');
module.exports = {
    entry:{
        index:path.resolve(__dirname,'../src/index.js')
    },
    mode:'development',
    devtool: 'cheap-module-eval-source-map',
    output:{
        filename:'[name].js',
        path:path.resolve(__dirname,'../dist/client')
    },
    resolve:{
        alias:{
            '@':path.resolve(__dirname,'../src')
        },
        extensions:['.js']
    },
    module:{
        rules:[{
            test:/\.js$/,
            use:'babel-loader',
            exclude:/node_modules/
        }]
    }
}


// 4、添加客户端代码的打包脚本

"build:client": "webpack --config build/webpack-client.config.js --watch"
// 运行一下
npm run build:client


// 5、server引用打包好的客户端资源
import express from 'express';
import React from 'react';
import {renderToString} from 'react-dom/server';
import App from './src/app';
const app = express();

app.use(express.static("dist"))
app.get('/',function(req,res){
    const content = renderToString(<App/>);
    res.send(`
        <!doctype html>
        <html>
            <title>ssr</title>
            <body>
                <div id="root">${content}</div>
                <script src="https://juejin.cn/client/index.js"></script>
            </body>
        </html>
    `);
});

app.listen(3000);

复制代码

经过上面的5步, 看起来没问题了, 但是我们的控制台会输出一些warnning。

ReactDOM.hydrate()和ReactDOM.render()的区别就是:

  • ReactDOM.render()会将挂载dom节点的所有子节点全部清空掉,再重新生成子节点。
  • ReactDOM.hydrate()则会复用挂载dom节点的子节点,并将其与react的virtualDom关联上。

也就是说ReactDOM.render()会将服务端做的工作全部推翻重做,而ReactDOM.hydrate()在服务端做的工作基础上再进行深入的操作

所以我们修改一下客户端的入口文件src/index.js, 将render修改为hydrate

import React from 'react';
import { hydrate } from 'react-dom';
import App from './app';
hydrate(<App/>,document.getElementById("root"));
复制代码
4、同构流程总结
  1. 服务端根据React代码生成html;
  2. 客户端发起请求, 收到服务端发送的html, 进行解析和展示;
  3. 客户端加载js等资源文件;
  4. 客户端执行js文件, 完成hydrate操作;
  5. 客户端接管整体应用。

2、路由 (简单使用原理)

在客户端渲染时, React提供了BrowserRouterHashRouter来供我们处理路由, 但是他们都依赖window对象, 而在服务端是没有window的。
但是react-router提供了StaticRouter, 为我们的服务端渲染做服务。

1、构建模拟页面

构造Login和User两个页面

// src/pages/login/index.js
import React from 'react';
export default class Login extends React.PureComponent{
    render(){
        return <div>登陆</div>
    }
}


// src/pages/user/index.js
import React from 'react';
export default class User extends React.PureComponent{
    render(){
        return <div>用户</div>
    }
}

复制代码
2、添加路由
  • 添加服务端路由
// server.js
import express from 'express';
import React from 'react';
import {renderToString} from 'react-dom/server';
import {StaticRouter,Route} from 'react-router';
import Login from '@/pages/login';
import User from '@/pages/user';

const app = express();
app.use(express.static("dist"))

app.get('*',function(req,res){
    const content = renderToString(<div>
        <StaticRouter location={req.url}>
            <Route exact path="/user" component={User}></Route>
            <Route exact path="/login" component={Login}></Route>
        </StaticRouter>
    </div>);

    res.send(`
        <!doctype html>
        <html>
            <title>ssr</title>
            <body>
                <div id="root">${content}</div>
                <script src="https://juejin.cn/client/index.js"></script>
            </body>
        </html>
    `);
});

app.listen(3000);
复制代码
  • 添加客户端路由
// src/index.js

import React from 'react';
import { hydrate } from 'react-dom';
import App from './app';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import User from './pages/user';
import Login from './pages/login';

hydrate(
    <Router>
        <Route path="/" component={App}>
            <Route exact path="/user" component={User}></Route>
            <Route exact path="/login" component={Login}></Route>
        </Route>
    </Router>,
    document.getElementById("root")
);

复制代码

上面的写法已经发现了一个问题, 就是我们这种写法非常的费劲;既要在客户端写一遍路由, 也要在服务端写一遍路由,于是我们采用路由同构来解决问题。

3、路由同构

两端路由的异同:

  • 共同点:路径和组件的映射关系是相同的
  • 不同点:路由引用的组件不一样, 或者说实现的方式不一样

路径和组件之间的关系可以用抽象化的语言去描述清楚,也就是我们所说路由配置化。

最后我们提供一个转换器,可以根据我们的需要去转换成服务端或者客户端路由。

// 1、 新建src/pages/notFound/index.js
// 新建404
import React from 'react';
export default ()=> <div>404</div>


// 2、路由配置文件
// src/router/routeConfig.js
import Login from '@/pages/login';
import User from '@/pages/user';
import NotFound from '@/pages/notFound';

export default [{
        type:'redirect', //自定义类型
        exact:true,
        from:'/',
        to:'/user'
    },{
        type:'route',
        path:'/user',
        exact:true,
        component:User
    },{
        type:'route',
        path:'/login',
        exact:true,
        component:Login
    },{
        type:'route',
        path:'*',
        render:({staticContext})=>{
            if (staticContext) staticContext.NOT_FOUND = true;
            return <NotFound/>
        }
}]


// 3、router转换器
import React from 'react';
import { createBrowserHistory } from "history";
import {Route,Router,StaticRouter,Redirect,Switch} from 'react-router';
import routeConfig from './routeConfig';

const routes = routeConfig.map((conf,index)=>{
    const {type,...otherConf} = conf;
    if(type==='redirect'){
        return <Redirect key={index} {...otherConf}/>;
    }else if(type ==='route'){
        return <Route key={index} {...otherConf}></Route>;
    }
});

export const createRouter = (type)=>(params)=>{
    if(type==='client'){
        const history = createBrowserHistory();
        return <Router history={history}>
            <Switch>
                {routes}
            </Switch>
        </Router>
     }else if(type==='server'){
        // const {location} = params;
        return <StaticRouter {...params}>
            <Switch>
                {routes}
            </Switch>
        </StaticRouter>
    }
}


// 4、客户端入口
// src/index.js
import React from 'react';
import { hydrate } from 'react-dom';
import App from './app';

hydrate(
    <App />,
    document.getElementById("root")
);


// 5、客户端 app.js
// src/app.js
import React from 'react';
import { createRouter } from './router'

class App extends React.PureComponent{
    render(){
        return createRouter('client')();
    }
};
export default App;


// 6、服务端入口
// server.js
import express from 'express';
import React from 'react';
import {renderToString} from 'react-dom/server';
import { createRouter } from './src/router'

const app = express();
app.use(express.static("dist"))

app.get('*',function(req,res){
    const content = renderToString(createRouter('server')({location:req.url}) );
    res.send(`
        <!doctype html>
        <html>
            <title>ssr</title>
            <body>
                <div id="root">${content}</div>
                <script src="https://juejin.cn/client/index.js"></script>
            </body>
        </html>
    `);
});

app.listen(3000);
复制代码
4、重定向问题

这里我们从/重定向到/user的时候, 可以看到html返回的内容和实现页面渲染的内容是不一样的。

这代表重定向操作是客户端来完成的, 而我们期望的是先访问index.html请求, 返回302, 然后出现一个新的user.html请求

// server.js
import express from 'express';
import React from 'react';
import {renderToString} from 'react-dom/server';
import { createRouter } from './src/router'

const app = express();
app.use(express.static("dist"))

app.get('*',function(req,res){
    const context = {};
    const content = renderToString(createRouter('server')({location:req.url, context}) );

    //当Redirect被使用时,context.url将包含重新向的地址
    if(context.url){
        //302
        return res.redirect(context.url);
    }else{
        if(context.NOT_FOUND) res.status(404);//判断是否设置状态码为404
        res.send(`
            <!doctype html>
            <html>
                <title>ssr</title>
                <body>
                    <div id="root">${content}</div>
                    <script src="https://juejin.cn/client/index.js"></script>

                </body>
            </html>
        `);
    }
});

app.listen(3000);
复制代码

3、升级版

1、服务端代码

相关库

  • react-router-config
    • renderRoutes
    • matchRoutes
  • express-http-proxy
  • react-helmet:head管理工具
const routes = [
  {
    component: Root,
    routes: [
      {
        path: "/",
        exact: true,
        component: Home
      },
      {
        path: "/child/:id",
        component: Child,
        routes: [
          {
            path: "/child/:id/grand-child",
            component: GrandChild
          }
        ]
      }
    ]
  }
];
//1、matchRoutes(routes, pathname)
import { matchRoutes } from "react-router-config";
const branch = matchRoutes(routes, "/child/23");
[
  routes[0],
  routes[0].routes[1]
]
branch[0].match.url;
branch[0].match.isExact;

// 2、renderRoutes(routes, extraProps = {}, switchProps = {})
import { renderRoutes } from "react-router-config";
import routes from '../Routes';
const App = () => {
    return (
        <BrowserRouter>
            <div>
                {renderRoutes(routes)}
            </div>
        </BrowserRouter>
    )
}
复制代码
2、代码分析
  • web服务器放置静态资源
  • api接口代理
  • ssr路由处理:路由匹配、数据获取、ssr
import express from 'express';
const app = express();
var server = app.listen(9002);

// 1. 静态服务器:客户端打包代码index.js放置在public中
app.use(express.static('public'));

// 2. api接口代理
import proxy from 'express-http-proxy';
app.use('/api', proxy('http://127.0.0.1', {
    proxyReqPathResolver: function (req) {
        console.log('111api', req.path, req.url);
        return '/api/' + req.url;
    }
}));

// 3. 路由处理:路由匹配、数据获取、ssr
app.get('*', function (req, res) { /* */}

// 3.1 路由匹配:req.path在路由配置中匹配
import routes from '../Routes';
import { matchRoutes } from 'react-router-config'
const matchedRoutes = matchRoutes(routes, req.path);

// 3.2 数据获取
const store = getStore(req);
// 让matchRoutes里面所有的组件,对应的loadData方法执行一次
const promises = [];
matchedRoutes.forEach(item => {
    if (item.route.loadData) {
        const promise = new Promise((resolve, reject) => {
            item.route.loadData(store).then(resolve).catch((e) => {
                console.log(`1111${item.route.path}error`, e);
            });
        })
        promises.push(promise);
    }
})

// 3.3 数据获取结束后进行ssr渲染
// 使用Promise.all保证所有数据加载完成
Promise.all(promises).then(() => {
    const context = { css: [] };
    const html = render(store, routes, req, context);
    if (context.action === 'REPLACE') {
        res.redirect(301, context.url)
    } else if (context.NOT_FOUND) {
        res.status(404);
        res.send(html);
    } else {
        res.send(html);
    }
}).catch((e) => {
    console.log('1111error', e);
})
复制代码
1、路由匹配
//react-router-config
//配置对象等价于:<Route>
export default [{
  path: '/',
  //接收参数component:no render or children
  //key:避免重复渲染组件,性能优化
  component: App, 
  loadData: App.loadData,
  routes: [
    { 
      path: '/',
      component: Home,
      exact: true,
      loadData: Home.loadData,
      key: 'home'
    }, { 
      path: '/translation',
      component: Translation,
      loadData: Translation.loadData,
      exact: true,
      key: 'translation'
    },
    {
      component: NotFound
    }
  ]
}];
const matchedRoutes = matchRoutes(routes, req.path);
复制代码
2、数据获取
//2.1、getStore
const reducer = combineReducers({
    home: homeReducer,
    header: headerReducer,
    translation: translationReducer
});
export const getStore = (req) => {
    // 改变服务器端store的内容,那么就一定要使用serverAxios
    return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios(req))));
}
//2.2、serverAxios读取文件内容
const createInstance = (req) => {
    return {
        get: (url) => {
            const dataPath = path.join(process.cwd(), 'public', url);
            const data = fs.readFileSync(`${dataPath}`, 'utf8');
            return Promise.resolve({
                    data: JSON.parse(data)
            });
        }
    }
};

//2.3、使用promise封装数据获取
matchedRoutes.forEach(item => {
    if (item.route.loadData) {
        const promise = new Promise((resolve, reject) => {
            item.route.loadData(store).then(resolve).catch((e) => {
                console.log(`${item.route.path}error`, e);
            });
        })
        promises.push(promise);
    }
})
//Home组件代码
ExportHome.loadData = (store) => {
    return store.dispatch(getHomeList())
}
//actions.js
export const getHomeList = () => {
    return (dispatch, getState, axiosInstance) => {
        return axiosInstance.get('/api/news.json')
            .then((res) => {
                const list = res.data.data;
                dispatch(changeList(list))
            });
    }
}
const changeList = (list) => ({
    type: CHANGE_LIST,
    list
})
//reducer.js
export default (state = defaultState, action) => {
    switch(action.type) {
        case CHANGE_LIST:
            return {
                ...state,
                newsList: action.list
            }
        default:
            return state;
    }
}
复制代码
3、渲染

拼接字符串返回:body、数据、样式

  • 使用StaticRouter匹配,使用renderToString渲染字符串
  • 数据挂载在window.context下
  • 样式放置在style标签中
const context = { css: [] };
const html = render(store, routes, req, context);
//util.js中render
export const render = (store, routes, req, context) => {
    const insertCss = (...styles) => {
        styles.forEach(style => context.css.push(style._getCss()))
    };
    //使用StaticRouter路由匹配
    const content = renderToString((
        <Provider store={store} >
            <StaticRouter location={req.path} context={context}>
                <Switch>
                    {renderRoutes(routes)}
                </Switch>
            </StaticRouter>
        </Provider>
    ));
    const helmet = Helmet.renderStatic();
    const cssStr = context.css.length ? context.css.join('\n') : '';
    return `
            <html>
                <head>
                    ${helmet.title.toString()}
${helmet.meta.toString()}
                    <style>${cssStr}</style>
                </head>
                <body>
                    <div id="root">${content}</div>
                    <script>
                        window.context = {
                            state: ${JSON.stringify(store.getState())}
                        }
                    </script>
                    <script src='https://juejin.cn/index.js'></script>
                </body>
            </html>
      `;
}
复制代码
3、客户端代码
  • 获取数据,进行挂载
  • 使用BrowserRouter路由
  • 使用ReactDom.hydrate渲染,复用模版
const store = getClientStore();
const App = () => {
    return (
        <Provider store={store}>
            <BrowserRouter>
                <div>{renderRoutes(routes)}</div>
            </BrowserRouter>
        </Provider>
    )
}
ReactDom.hydrate(<App />, document.getElementById('root'))
复制代码
1、数据获取
export const getClientStore = () => {
    // 使用window.context上的数据设置初始化
    const defaultState = window.context.state;
    // 改变客户端store的内容,一定要使用clientAxios
    return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios)));
}
//request.js中clientAxios
const instance = axios.create({
    baseURL: '/',
    params: {
        secret: config.secret
    }
});
复制代码
2、组件交互
  • 初始数据通过服务端获取直接加载
  • 后续交互数据通过客户端发起请求获取
/* 代码结构
-Home
    -store
        -action.js
        -constants.js
        -index.js
        -reducer.js    
    -index.js
    -style.css
*/
class Home extends Component {
    onClickItem(title) {
        console.log('111home', title);
    }
    //该生命周期不会在服务端执行,服务端加载数据使用loadData
    componentDidMount() {
        //服务端有值,客户端直接使用不会重新获取数据
        if (!this.props.list.length) {
                this.props.getHomeList();
        }
    }
    getList() {
        const { list } = this.props;
        return list.map(item => <div className={styles.item} key={item.id} onClick={() => { this.onClickItem(item.title) }} >{item.title}</div>)
    }
    render() {
        return (
            <Fragment>
                <Helmet>
                    <title>首页新闻</title>
                    <meta name="description" content="新闻页面 - 丰富多彩的资讯" />
                </Helmet>
                <div className={styles.container}>
                    {this.getList()}
                </div>
            </Fragment>
        )
    }
}
const mapStateToProps = state => ({
	list: state.home.newsList
});
const mapDispatchToProps = dispatch => ({
    getHomeList() {
        dispatch(getHomeList());
    }
});
const ExportHome = connect(mapStateToProps, mapDispatchToProps)(withStyle(Home, styles));
ExportHome.loadData = (store) => {
	return store.dispatch(getHomeList())
}
export default ExportHome;
复制代码
4、打包
1、相关包
  • npm-run-all:并行运行
  • nodemon:node服务器,支持热更新
2、打包命令
  "scripts": {
    "start": "node ./build/bundle.js", //运行服务器打包代码
    "dev": "npm-run-all --parallel dev:**", //并行运行dev: xx
    "dev:start": "nodemon --watch build --exec node "./build/bundle.js"",
    //--watch选项,配置文件修改,则重新打包
    "dev:build:server": "webpack --config webpack.server.js --watch", 
    "dev:build:client": "webpack --config webpack.client.js --watch"
  },
复制代码
3、打包配置
  • webpack.base.js
  • webpack.client.js
  • webpack.server.js
//1. webpack.base.js: babel-loader

//2. webpack.client.js
entry: './src/client/index.js',
output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'public')
},
loader: style-loader\css-loader\url-loader
module.exports = merge(config, clientConfig);

//3. webpack.server.js
entry: './src/server/index.js',
output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'build')
},
target: 'node', //不必把node内置模块代码进行打包
//webpack-node-externals,这个库会扫描node_modules文件夹中的所有node_modules名称,并构建一个外部函数,告诉Webpack不要捆绑这些模块或其任何子模块
externals: [nodeExternals()],
loader: isomorphic-style-loader/url-loader
//只在对应的DOM元素上生成class类名,然后返回生成的CSS样式代码,将css文件可以转化为style标签插入到html的
复制代码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享