markfinger / python-react

Server-side rendering of React components
MIT License
1.62k stars 116 forks source link

easier helper functions instead of webpack #24

Closed pirate closed 9 years ago

pirate commented 9 years ago

After using this package for a little while, I quickly grew tired of dealing with webpack, so I wrote some helper functions to eliminate the need for it entirely.

myproject/utils.py

from django_react.render import render_component
from django.contrib.staticfiles import finders
from django.shortcuts import render

def prerender_component(jsx_path, *arg, **argv):
    preren_path = finders.find(jsx_path).replace('.jsx', '_prerenderable.jsx')
    with file(preren_path, 'w+') as prerenderable_jsx:
        prerenderable_jsx.write("import React from 'react';\n");
        with file(finders.find(jsx_path), 'r') as jsx_file:
            prerenderable_jsx.write(jsx_file.read())
        prerenderable_jsx.write("\nexport default render;");
    return render_component(preren_path, *arg, **argv)

def render_react(request, template_path, context, *arg, **argv):
    """add the prerendered html to any react components found in context"""
    # pass it a context formatted like this:
    # context = {
    #     'components': {
    #         'MyComponent': {                          # component name
    #             'jsx_path': 'test.jsx',               # relative to static root
    #             'props': {
    #                 'themessage': 'barbarblackshee',
    #             },
    #             'html': 'generated by this function'
    #         },
    #     },
    #     ...
    # }
    # then use this in the template to get the component's html
    # {{components.MyComponent.html}}
    # and this to access its props
    # {{components.MyComponent.props.themessage}}
    if 'components' in context:
        for name, component in context['components'].iteritems():
            context['components'][name]['html'] = prerender_component(component['jsx_path'], props=component['props']) if 'jsx_path' in component else 'No JSX Path Specified'

    return render(request, template_path, context, *arg, **argv)

I use render_react inside my view, in place of plain render, e.g.: views.py

from myproject.utils import render_react

def react_test(request):
    context = {
        'components': {
            'main': {
                'jsx_path': 'test.jsx',
                'props': {
                    'themessage': 'baabaablacksheep',
                },
            },
        },
    }

    return render_react(request, 'frontend/test.html', context)

And my template: templates/frontend/test.html

<html>
    <head>
        <title>Django + React demo</title>
    </head>
    <body>
        <br><br><br>
        <div id="tcomp">
            {{components.main.html}}
        </div>
        <script src="{{static_url}}/react/react.js" type="text/javascript"></script>
        <script src="{{static_url}}/react/JSXTransformer.js" type="text/javascript"></script>
        <script type="text/jsx" src="{{static_url}}/test.jsx"></script>
        <script type="text/jsx">
            React.render(
                <RandomMessage themessage="{{components.main.props.themessage}}"/>,
                document.getElementById('tcomp')
            );
        </script>
    </body>
</html>

static/test.jsx

var TestView = React.createClass({
    render: function() {
        return (
            <p>{this.props.message}</p>
        )
    }
});

var render = TestView;

That way the prerendered html is properly displayed, and I can mount the component using the same JSX file used to prerender it. What do you think? If I wrote a pull request implementing these as part of the package, would you consider merging it? I'm open to suggestions/criticism as well.

markfinger commented 9 years ago

Based on a superficial scan, this looks kinda similar to how I had django-react set up originally. You pointed it at a JSX file which exported a component, it would render it and provide a hook to dump in JS to mount it over the top of the pre-rendered html. I ended up stripping out those helpers because they necessitated that your app was tightly coupled to React's mounting strategy - if you wanted to hit an API or interact with the DOM you would have to run your code in the componentDidMount function which only runs on the client-side.

For a lot of use-cases where you only really want to add a little bit of interactivity to your front-end, I think it would be useful to have some helpers in there. My big concern is that the more opaque the helpers, the more limiting the workflow becomes.

Anyway, a possible workflow...

# in your view

mountable_component = render_component(path_to_source, props, mountable=True)
<!-- in your template -->

<!-- insert the markup -->
{{ mountable_component }}

<!-- insert JS to mount the component -->
{{ mountable_component.render_js }}

From memory, the issues that I experienced when I implemented something similar:

I'll leave this open and mull it over for a few days.

pirate commented 9 years ago

Makes a lot of sense, the limitation on componentDidMount is definitely not desirable. What about including the render_react helper method, but making it optional instead of using it as the primary render method?

I have to find a better way of handling the creation of the _prerender.jsx files, it's pretty hacky right now, definitely not production-ready.

I think the ability to specify and manipulate components in the context variable is really powerful though:

'components': {
            'Main': {
                'jsx_path': 'Main.jsx',
                'props': {
                    'first_name': 'firstnamename',
                    'last_name': 'lastnamename',
                    'username': 'adfsdf',
                    'total_lead_count': '{:,}'.format(52000),
                    'notification_count': 101,
                    'notifications': [
                        {'icon': 'users', 'color': 'red', 'text': "50 new leads added today"},
                        {'icon': 'download', 'color': 'aqua', 'text': "25 users bought stuff"},
                        {'icon': 'users', 'color': 'green', 'text': "418 duplicates need to be resolved"},
                        {'icon': 'users', 'color': 'yellow', 'text': "You changed your username"},
                        {'icon': 'users', 'color': 'red', 'text': "50 new leads abbb today"},
                        {'icon': 'users', 'color': 'red', 'text': "2 new BDRs joined"},
                    ],
                    'page_name': 'MAIN',
                    'page_description': 'The Home Dashboard',
                },
            },
        },
markfinger commented 9 years ago

Coincidentally, it's already bundling the components when the WATCH_SOURCE setting is True (defaults to DEBUG). The renderer uses webpack to detect changes and rebuild everything on demand, mostly to circumvent Node's module cache.

The component's render bundle wouldn't be too easily usable outside of the Node though, the client-side would probably want global variables and node wants commonjs.

I suppose you could probably point webpack at a shim which exposes itself via a global variable. If you informed webpack to not parse the render bundle, it would be fairly performant as well.

Though, this still leaves the issue with bundling react along and potentially duplicating its codebase.

I suppose you could bundle react with the component if DEBUG and - if desired - generate a bundle omitting react for production.

pirate commented 9 years ago

After a few more days of building a data dashboard with this, I've revised my method entirely. I think if people want to do it the way I did in the above snippet, that's perfectly reasonable, and they can just write that code snippet, no need to include it in the library. I was too quick to try and improve it before using it long enough haha.

On a related topic, what is your recommended way of building "Adapters" or API method groups to talk to mounted react components?

I've chosen to ditch Django templating entirely and compose everything in views and jsx files. I have a single base.html file with basic head tags, shared scripts (react), and css, then it has the pre-rendered html and the props.

I then have several jsx (or compiled) files that represent my app pages, and I can use all the usual require()/import stuff to inherit an reuse components between them. In my views I have ReactPage(View) classes that choose the appropriate jsx file to render, and compose smaller view functions to get the necessary props to pass to the pre-renderer and the template.

Incidentally I then can use those smaller view functions to make a REST api or other pages. Not sure if it's the best system, but it's what I've figured out so far. Are there any established best practices? What works best for you?

I still use this function through:

def prerender_component(jsx_path, *arg, **argv):
    preren_path = finders.find(jsx_path).replace('.jsx', '_prerenderable.jsx')
    with file(preren_path, 'w+') as prerenderable_jsx:
        prerenderable_jsx.write("import React from 'react';\n");
        with file(finders.find(jsx_path), 'r') as jsx_file:
            prerenderable_jsx.write(jsx_file.read())
        prerenderable_jsx.write("\nexport default render;");
    return render_component(preren_path, *arg, **argv)

def render_react(request, template_path, context, *arg, **argv):
    """add the prerendered html to any react components found in context"""

    context['component']['html'] = prerender_component(component['jsx_path'], props=component['props'])
    context['component']['props_json'] = SafeString(json.dumps(component['props']))

    return render(request, template_path, context, *arg, **argv)

pages.py react_components.py

RasmusKlett commented 9 years ago

Did anyone make progress on this? I'm trying to upgrade from 0.9 to 0.10, and the old render_js fit my project very well before, apart from the horrible speed. I don't think my requirements justify polluting my project with Webpack, and I can't imagine I'm the only one. I think there is much value in a library making React available to Django developers, without forcing them to learn half the javascript ecosystem as well.

markfinger commented 9 years ago

I've excised and started rewriting the service, my intention is still to use a CommenJS bundled version of the component to circumvent the caches. You wouldn't be able to plug that straight into the frontend, but you could shim it pretty easily by wrapping it in another bundle with exports some mounting js.

The 0.9 mount script could be easily copy-pasted into a JS file. If you get the absolute path to the bundled component that we generate for rendering, you could simply require it, bundle that mount script, and then the frontend could pass in a selector and props to mount it.

The big issue I can see with this is the risk of generating multiple bundles which duplicate your codebase. I can't think of a good solution though, so maybe it should simply be documented with a long list of caveats.

PRs are more than welcome.

markfinger commented 9 years ago

Hmm, this shouldn't be too hard actually.

The bundle target could be switched to umd - http://webpack.github.io/docs/configuration.html#output-librarytarget

Can probably infer an exportable var from the component's path. This'll need to be overridable (var kwarg) but for most cases you could probably just mangle the filename and append a hash of the full path - so /path/to/some-app/Foo-Component.jsx becomes Foo_Component_bbc717359e77c7150d55fe835b82c469 - http://webpack.github.io/docs/configuration.html#output-library

Will probably need a bundle kwarg to the render call, so that it uses webpack even if not watching.

The render_js method could be ported across mostly intact.

markfinger commented 9 years ago

If watch or bundle kwargs, bundle it first, then pass into the renderer.

Will probably need to recouple to django-webpack, but this would enable all the webpack stuff to be excised from the renderer. Re #28

This is blocked by the rewrite of the webpack service :crying_cat_face: but that'll land shortly

markfinger commented 9 years ago

Might have to shim around React and the module exports though. Probably use externals and hope that the UMD setup will try to require it on the server and access a global on the client side. http://webpack.github.io/docs/configuration.html#externals

Otherwise, will need to do a bit of a song and dance involving exporting both React and the component. If so, will probably need support for an option indicating the name that the component's exported as.

RasmusKlett commented 9 years ago

Very impressive progress you're making! I'm sorry I don't know enough of the Javascript stuff to be of any help, but I will happily test it in my setup and submit PR's for any bugs I may find when it is ready. Are you planning on a new Pip release soon, or should I just get it from here?

markfinger commented 9 years ago

Thanks, @RasmusKlett.

I'm in the process of updating a project to use the new codebase. If everything goes ok, I'll push a new release up in a day or two.

markfinger commented 9 years ago

@RasmusKlett @pirate

I've just pushed 0.11.0, which adds comparable functionality to what used to be in an older version django-react.

You can now now call {{ bundle.render_js }} to remount the component back over the renderer markup. You'll need to make sure that you've got a <script> element pointing to a React, it'll need to be above the render_js call.

This covers the typical use-case where you want to pre-render markup and offer some interactivity once the page has loaded the JS.

If you wanted to do a fairly complicated bundling process, or have a different mounting strategy, you'll need to use webpack directly.

I've updated the docs and the example to use the latest codebase:

Feel free to pop questions/issues in here, if you experience any issues.

RasmusKlett commented 9 years ago

Looks great! I think I can find time to upgrade and try it out on monday, will let you know if I find anything interesting to say.

pirate commented 9 years ago

Awesome, thanks for improving this @markfinger ! I'll test it out next week when I'm back on the project we're using it with.